A radiogroup-style segmented control with a sliding indicator behind the active option. Keyboard navigable (←/→/↑/↓ to step, Home/End to jump). Equal-width segments via flex-1; indicator position computed from activeIdx so any segment count works. Inspired by Notion.
View
value: board
Range
value: week
Click a segment or use ←/→ · Home / End jump to ends · Indicator slides between options
// Dependencies: react ^18, lucide-react
import React, { useId, useRef } from 'react';
import type { LucideIcon } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
export type Segment = {
id: string;
label: string;
icon?: LucideIcon;
};
type Props = {
segments: Segment[];
value: string;
onChange: (id: string) => void;
ariaLabel?: string;
};
export function SegmentedControl({
segments,
value,
onChange,
ariaLabel = 'View mode',
}: Props) {
const groupId = useId();
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);
const activeIdx = Math.max(0, segments.findIndex((s) => s.id === value));
const move = (delta: number) => {
const next = (activeIdx + delta + segments.length) % segments.length;
onChange(segments[next].id);
requestAnimationFrame(() => btnRefs.current[next]?.focus());
};
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
move(1);
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
move(-1);
} else if (e.key === 'Home') {
e.preventDefault();
onChange(segments[0].id);
requestAnimationFrame(() => btnRefs.current[0]?.focus());
} else if (e.key === 'End') {
e.preventDefault();
const last = segments.length - 1;
onChange(segments[last].id);
requestAnimationFrame(() => btnRefs.current[last]?.focus());
}
};
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className="relative inline-grid p-1 bg-zinc-800/60 border border-white/[0.06] rounded-lg"
style={{ gridTemplateColumns: `repeat(${segments.length}, 1fr)` }}
onKeyDown={handleKey}
>
<div
aria-hidden
className="absolute top-1 left-1 h-[calc(100%-0.5rem)] bg-zinc-900 border border-white/[0.08] rounded-md shadow-sm transition-transform duration-200 ease-out"
style={{
width: `calc((100% - 0.5rem) / ${segments.length})`,
transform: `translateX(${activeIdx * 100}%)`,
}}
/>
{segments.map((s, i) => {
const isActive = i === activeIdx;
const Icon = s.icon;
return (
<button
key={s.id}
ref={(el) => { btnRefs.current[i] = el; }}
id={`${groupId}-${s.id}`}
role="radio"
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(s.id)}
className={cn(
'relative z-10 w-full flex items-center justify-center gap-1.5 px-4 py-1.5 text-sm font-medium transition-colors whitespace-nowrap rounded-md',
isActive ? 'text-white' : 'text-zinc-500 hover:text-zinc-300'
)}
>
{Icon && <Icon className="w-3.5 h-3.5" />}
{s.label}
</button>
);
})}
</div>
);
}Unlock to copy
Free access to all patterns
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Status Badge System
Linear
Segmented Control
Notion