Skip to content
← Components

Segmented control

dub.co's product switcher — pill tabs where the active option lifts onto a white card.

  • tabs
  • dub
  • navigation
Source

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>
	);
}
esc