Sliding segmented control
A dark pill slides between Day, Week and Month and stretches or shrinks to fit the label it lands on.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useLayoutEffect, useRef, useState } from "react";
import styles from "./demo.module.css";
const OPTIONS = [
{ label: "Day", note: "12 check-ins today" },
{ label: "Week", note: "64 check-ins this week" },
{ label: "Month", note: "248 check-ins this month" },
];
export default function Demo() {
const group = useRef<HTMLDivElement>(null);
const pill = useRef<HTMLSpanElement>(null);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const [index, setIndex] = useState(1);
// Size and place the pill under the selected option, again whenever the group resizes.
useLayoutEffect(() => {
const place = () => {
const b = buttons.current[index];
if (!b || !pill.current) return;
pill.current.style.width = `${b.offsetWidth}px`;
pill.current.style.transform = `translateX(${b.offsetLeft}px)`;
};
place();
const raf = requestAnimationFrame(() => pill.current?.classList.add(styles.ready));
const observer = new ResizeObserver(place);
if (group.current) observer.observe(group.current);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
};
}, [index]);
const select = (i: number) => {
const next = (i + OPTIONS.length) % OPTIONS.length;
setIndex(next);
buttons.current[next]?.focus();
};
const keyDown = (event: React.KeyboardEvent) => {
const moves: Record<string, number> = { ArrowRight: index + 1, ArrowDown: index + 1, ArrowLeft: index - 1, ArrowUp: index - 1, Home: 0, End: OPTIONS.length - 1 };
if (!(event.key in moves)) return;
event.preventDefault();
select(moves[event.key]);
};
return (
<div className={styles.root}>
<div className={styles.stack}>
<div ref={group} className={styles.group} role="radiogroup" aria-label="Date range" onKeyDown={keyDown}>
<span ref={pill} className={styles.pill} aria-hidden="true" />
{OPTIONS.map((option, i) => (
<button
key={option.label}
ref={(el) => {
buttons.current[i] = el;
}}
type="button"
role="radio"
aria-checked={i === index}
tabIndex={i === index ? 0 : -1}
className={styles.option}
onClick={() => select(i)}
>
{option.label}
</button>
))}
</div>
<p className={styles.note} aria-live="polite">{OPTIONS[index].note}</p>
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.stack { display: grid; justify-items: center; gap: 14px; }
.group {
position: relative; display: inline-flex; padding: 4px; border-radius: 999px;
background: var(--white); border: 1px solid var(--line);
}
.pill {
position: absolute; top: 4px; bottom: 4px; left: 0; border-radius: 999px; background: var(--ink);
box-shadow: 0 6px 14px -8px var(--ink);
}
.ready { transition: transform .45s var(--out), width .45s var(--out); }
.option {
position: relative; min-height: 42px; padding: 0 22px; border: 0; border-radius: 999px; background: none;
color: var(--muted); font-size: 15px; font-weight: 600; cursor: pointer; -webkit-tap-highlight-color: transparent;
transition: color .25s;
}
.option:hover { color: var(--ink); }
.option[aria-checked="true"] { color: var(--white); }
.option:focus-visible { outline-offset: 1px; }
.note { font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .ready { transition: none; } }
Use it for
- Switching the time range or view of a chart or list
- Two to five short, mutually exclusive options
Avoid
- More than five options or long labels, where a select fits better
- Navigation between pages, which should be links or tabs
How it works
One absolutely positioned pill sits behind the options. A layout effect reads the selected button offsetLeft and offsetWidth and writes them to the pill as translateX and width, both transitioned with the shared easing. The transition class is added a frame after the first placement so the pill does not slide in on load, and a ResizeObserver re-measures when fonts or the container change.
Accessibility
The control is a radiogroup with role radio buttons and aria-checked. Only the selected option is in the tab order, and arrow keys, Home and End move the selection and focus like native radios. The summary line below is a polite live region. Reduced motion moves the pill without sliding.