Original by Plinth ยท MIT
Navigation
Segmented Control
A pill that slides and stretches between tabs, with arrow-key navigation.
#navigation#tabs#segmented#indicator#keyboard
Install
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-selected="true"] { color: #14121a; }
.seg__tab:focus-visible { outline: 2px solid #ffca7a; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.seg__indicator { transition: none; }
}
`;
export function SegmentedControl({
options = ['Preview', 'Code', 'Prompt'],
onChange,
}: {
options?: string[];
onChange?: (value: string, index: number) => void;
}) {
const [active, setActive] = useState(0);
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 (!tab || !bar) 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]);
function select(i: number) {
setActive(i);
onChange?.(options[i] ?? '', i);
}
return (
<>
<style>{css}</style>
<div className="seg" role="tablist" aria-label="View">
{/* 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="tab"
aria-selected={i === active}
onClick={() => select(i)}
onKeyDown={e => {
const next = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : -1;
if (next < 0 || next >= options.length) return;
e.preventDefault();
tabs.current[next]?.focus();
select(next);
}}
>
{option}
</button>
))}
</div>
</>
);
}