Navigation
Segmented Control
A pill that slides and stretches between tabs, with arrow-key navigation.
#navigation#tabs#segmented#indicator#keyboard
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/segmented-control.json'use client';
import { useEffect, useRef, useState } from 'react';
const css = `
.seg {
position: relative;
display: inline-flex;
padding: 4px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
}
.seg__indicator {
position: absolute;
top: 4px;
bottom: 4px;
left: 0;
border-radius: 999px;
background: #f3f0e9;
/* Position and size both animate, so the pill stretches into a wider tab
rather than jumping and then resizing. */
transform: translateX(var(--x, 0px));
width: var(--w, 0px);
transition:
transform 340ms cubic-bezier(0.22, 1, 0.36, 1),
width 340ms cubic-bezier(0.22, 1, 0.36, 1);
}
.seg__tab {
position: relative;
z-index: 1;
padding: 8px 18px;
border: 0;
border-radius: 999px;
background: none;
color: rgba(243, 240, 233, 0.62);
font: 500 14px/1 ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
transition: color 220ms ease;
}
.seg__tab:hover { color: rgba(243, 240, 233, 0.9); }
.seg__tab[aria-checked="true"] { color: #14121a; }
.seg__tab:focus-visible { outline: 2px solid #ffca7a; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.seg__indicator { transition: none; }
}
`;
const DEFAULT_OPTIONS = ['Preview', 'Code', 'Prompt'];
/** A single choice, so use radios rather than tabs without associated panels. */
export function SegmentedControl({
options = DEFAULT_OPTIONS,
label = 'View',
value,
defaultValue,
onChange,
}: {
options?: string[];
label?: string;
value?: string;
defaultValue?: string;
onChange?: (value: string, index: number) => void;
}) {
const [internal, setInternal] = useState(defaultValue ?? options[0] ?? '');
const requested = value ?? internal;
const selected = options.includes(requested) ? requested : options[0];
const active = selected === undefined ? -1 : options.indexOf(selected);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const indicator = useRef<HTMLSpanElement>(null);
// Measured from the DOM rather than computed, because tab widths depend on
// the rendered text. offsetLeft is relative to .seg (the positioned
// ancestor), so there is no rect maths and nothing to correct for scroll.
useEffect(() => {
function place() {
const tab = tabs.current[active];
const bar = indicator.current;
if (!bar) return;
if (!tab) { bar.style.setProperty('--w', '0px'); return; }
bar.style.setProperty('--x', `${tab.offsetLeft}px`);
bar.style.setProperty('--w', `${tab.offsetWidth}px`);
}
place();
// Fonts land after first paint and change tab widths, so re-measure rather
// than leaving the pill misaligned.
void document.fonts?.ready.then(place);
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
}, [active, options]);
function select(i: number) {
if (value === undefined) setInternal(options[i]);
onChange?.(options[i] ?? '', i);
}
return (
<>
<style>{css}</style>
<div className="seg" role="radiogroup" aria-label={label}>
{/* A sibling, not a child of the active tab: one element sliding
between positions animates, whereas a per-tab background can only
cross-fade. */}
<span className="seg__indicator" ref={indicator} aria-hidden="true" />
{options.map((option, i) => (
<button
key={option}
ref={el => {
tabs.current[i] = el;
}}
className="seg__tab"
type="button"
role="radio"
aria-checked={i === active}
tabIndex={i === active ? 0 : -1}
onClick={() => select(i)}
onKeyDown={e => {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const next = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? (i + 1) % options.length :
e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? (i - 1 + options.length) % options.length :
e.key === 'Home' ? 0 : e.key === 'End' ? options.length - 1 : -1;
if (next < 0) return;
e.preventDefault();
tabs.current[next]?.focus();
select(next);
}}
>
{option}
</button>
))}
</div>
</>
);
}