React island, hydrated only when it scrolls into view (client:visible). Styling uses the archive’s
dub token classes (bg-bg-subtle, text-content-emphasis), so it adapts to dark mode for free.
Usage
<SegmentedControl
options={[
{ value: 'monthly', label: 'Monthly' },
{ value: 'yearly', label: 'Yearly' },
]}
onChange={(value) => setPlan(value)}
/>
SegmentedControl.tsx
import { useId, useState } from 'react';
export interface SegmentedOption {
value: string;
label: string;
/** Optional accent for the little product square, dub-style. */
color?: string;
}
interface Props {
options: SegmentedOption[];
defaultValue?: string;
onChange?: (value: string) => void;
}
/** dub.co's product switcher: pill tabs where the active one lifts onto a white card. */
export default function SegmentedControl({ options, defaultValue, onChange }: Props) {
const [value, setValue] = useState(defaultValue ?? options[0]?.value);
const id = useId();
return (
<div role="tablist" aria-label="Choose a product" className="inline-flex gap-1 rounded-xl bg-bg-subtle p-1">
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
id={`${id}-${option.value}`}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setValue(option.value);
onChange?.(option.value);
}}
className={`flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-all ${
active
? 'bg-bg-default text-content-emphasis shadow-[0_1px_3px_rgb(0_0_0/0.08)] ring-1 ring-border-subtle'
: 'text-content-subtle hover:text-content-emphasis'
}`}
>
{option.color && <span className="size-3.5 rounded-[4px]" style={{ background: option.color }} />}
{option.label}
</button>
);
})}
</div>
);
}