The problem
An icon-only row is compact, but it is also unlabelled — people cannot tell what the options are without hovering each one, and hovering is not available on touch. Labelling every icon solves that and immediately stops fitting. Expanding only the selection keeps the row small while always naming the current choice, which is the one people most need to read back.
Use cases
- A mailbox or workspace switcher in a compact toolbar
- Filter modes where the active filter must stay legible
- Any small single-select where the options are recognisable as icons but the current one needs a name
Not for: more than four options, or options whose icons are not self-evident. If people cannot guess an icon before selecting it, expanding the one they already chose does not help them.
Installation
npm install motionThen copy ExpandingSegments.tsx. It depends only on @/lib/cn.
Usage
import ExpandingSegments from "@/patterns/expanding-segments/ExpandingSegments";
import { Mail, Calendar, Bell } from "lucide-react";
<ExpandingSegments
label="Mailbox view"
segments={[
{
id: "inbox",
label: "Inbox",
icon: <Mail />,
accentClassName: "text-blue-600",
},
{ id: "planner", label: "Planner", icon: <Calendar /> },
{ id: "alerts", label: "Alerts", icon: <Bell /> },
]}
onValueChange={(id) => setView(id)}
/>;API
| Prop | Type | Default | Description |
|---|---|---|---|
segments | readonly Segment[] | — | The options. Empty renders nothing. |
label | string | — | Names the group for assistive technology. |
defaultValue | string | first segment | Uncontrolled initial selection. |
value | string | uncontrolled | Controlled selection. Pass with onValueChange. |
onValueChange | (id: string) => void | — | Fires on every selection change. |
className | string | — | Merged onto the root. |
Segment: { id: string; label: string; icon: ReactNode; accentClassName?: string }.
Keyboard
| Key | Action |
|---|---|
Tab | Enter and leave the group — it is a single tab stop |
← → | Move the selection, wrapping at both ends |
↑ ↓ | Same as left and right |
Home / End | First or last segment |
Accessibility
The original was three <div onClick> elements, so the whole control was
unreachable by keyboard — there was no way to change the selection without
a mouse. It is now a role="radiogroup" of role="radio" buttons following
the WAI-ARIA radiogroup pattern: arrows move selection and focus together, and
roving tabindex keeps the group to one tab stop rather than three.
aria-checked carries the selection, so it is announced without relying on
the label expanding — which is a visual signal only.
Each icon is aria-hidden; the accessible name comes from the visible label,
which is always in the DOM even when it is visually collapsed to zero opacity.
That means a screen-reader user hears all three names, not just the selected
one.
Under prefers-reduced-motion the width change and the sheen are dropped and
the selection updates immediately. The label still swaps, so nothing is lost.
Known limitation: the collapsed labels are transparent rather than
hidden, so they remain in the accessibility tree by design — that is what
makes the unselected options announceable. It also means they are inside the
pill's overflow-hidden box rather than removed, which is fine at three
options and would need revisiting at ten.
Performance
Animates width, which is a layout property and normally avoided. It is kept
here deliberately: the pill's expansion is the interaction, and Motion's
layout prop produces a visibly different feel — it interpolates a transform
between measured boxes, which distorts the icon and label mid-flight. One
element, one axis, bounded by a spring that settles in about 400ms.
The spring is kept verbatim from the original at 300/20 (damping ratio
0.577) rather than mapped onto spring.snappy. The ratio is close, but the
lower stiffness makes it about 15% slower and that difference is legible in a
control this small.
The sheen runs only on the segment that just became selected, so it reads as confirmation rather than ambient decoration, and it is skipped entirely under reduced motion.
Source
The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.
"use client";
import { useCallback, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/cn";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface Segment {
id: string;
label: string;
icon: React.ReactNode;
/** Text colour class applied while selected, e.g. `"text-blue-600"`. */
accentClassName?: string;
}
export interface ExpandingSegmentsProps extends Omit<
React.ComponentPropsWithoutRef<"div">,
"onChange" | "defaultValue"
> {
segments: readonly Segment[];
/** Names the group for assistive technology, e.g. "Mailbox view". */
label: string;
defaultValue?: string;
value?: string;
onValueChange?: (id: string) => void;
}
/* -------------------------------------------------------------------------- */
/* Motion */
/* -------------------------------------------------------------------------- */
/*
* Kept verbatim from the original rather than mapped onto a shared token.
* 300/20 is ratio 0.577 — close to `spring.snappy` in character but ~15%
* slower, and the difference is audible in a control this small. Consolidating
* it would be tidier and would not feel the same.
*/
const widthSpring = { type: "spring" as const, stiffness: 300, damping: 20 };
const COLLAPSED_WIDTH = 48;
const EXPANDED_WIDTH = 110;
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
/**
* A row of icon buttons where only the selected one names itself.
*
* Icon-only rows are compact but unlabelled — people cannot tell what the
* icons do without hovering each one, and labelling all of them will not fit.
* Expanding just the selection keeps the row small while always naming the
* current choice, which is the one people most need to read back.
*/
export function ExpandingSegments({
segments,
label,
defaultValue,
value: controlledValue,
onValueChange,
className,
...rest
}: ExpandingSegmentsProps) {
const reduce = useReducedMotion();
const uid = useId();
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [uncontrolled, setUncontrolled] = useState(
defaultValue ?? segments[0]?.id,
);
const isControlled = controlledValue !== undefined;
const selected = isControlled ? controlledValue : uncontrolled;
const select = useCallback(
(id: string) => {
if (!isControlled) setUncontrolled(id);
onValueChange?.(id);
},
[isControlled, onValueChange],
);
/**
* APG radiogroup: arrows move selection *and* focus, wrapping at both ends.
* The group is one tab stop — Tab enters and leaves, it does not walk the
* options.
*/
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const keys = [
"ArrowRight",
"ArrowDown",
"ArrowLeft",
"ArrowUp",
"Home",
"End",
];
if (!keys.includes(event.key)) return;
event.preventDefault();
const last = segments.length - 1;
const next =
event.key === "ArrowRight" || event.key === "ArrowDown"
? (index + 1) % segments.length
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? (index - 1 + segments.length) % segments.length
: event.key === "Home"
? 0
: last;
select(segments[next].id);
buttonRefs.current[next]?.focus();
},
[segments, select],
);
if (segments.length === 0) return null;
return (
<div
role="radiogroup"
aria-label={label}
className={cn("flex items-center justify-center gap-2.5", className)}
{...rest}
>
{segments.map((segment, index) => {
const isSelected = segment.id === selected;
const accent = isSelected
? (segment.accentClassName ?? "text-content")
: "text-content";
return (
<motion.button
key={segment.id}
ref={(node) => {
buttonRefs.current[index] = node;
}}
type="button"
role="radio"
aria-checked={isSelected}
// Roving tabindex: the group is a single tab stop.
tabIndex={isSelected ? 0 : -1}
onClick={() => select(segment.id)}
onKeyDown={(event) => handleKeyDown(event, index)}
initial={false}
animate={{ width: isSelected ? EXPANDED_WIDTH : COLLAPSED_WIDTH }}
transition={reduce ? { duration: 0.01 } : widthSpring}
whileHover={reduce ? undefined : { scale: 1.05 }}
className={cn(
"focus-ring relative flex h-12 cursor-pointer items-center",
"overflow-hidden rounded-full bg-surface will-change-transform",
)}
>
<span
aria-hidden="true"
// The box matches the glyph. It used to be `size-6` around a
// `size-8` icon, and `place-items-center` does not centre an item
// that overflows its own grid area — it pins it to the start, so
// the icon rendered 4px low and 4px right of where it belonged.
className={cn(
"absolute left-3 grid size-8 place-items-center [&>svg]:size-8",
accent,
)}
>
{segment.icon}
</span>
<motion.span
initial={false}
animate={{
opacity: isSelected ? 1 : 0,
x: isSelected ? 0 : -20,
}}
transition={reduce ? { duration: 0.01 } : { duration: 0.2 }}
// 44px clears the icon exactly: 12px of `left-3` plus its 32px box.
className="relative ml-11 font-semibold whitespace-nowrap"
>
<span className={accent}>{segment.label}</span>
{/*
Sheen sweep on selection. Decorative, and deliberately kept —
it is part of this interaction's character, and it only ever
runs on the item that just became selected, so it reads as
confirmation rather than ambient decoration.
*/}
{isSelected && !reduce ? (
<motion.span
key={`${uid}-${segment.id}-sheen`}
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-r from-transparent via-surface to-transparent opacity-60"
initial={{ x: "-100%" }}
animate={{ x: "200%" }}
transition={{ duration: 1.2, ease: "easeInOut" }}
style={{ mixBlendMode: "overlay" }}
/>
) : null}
</motion.span>
</motion.button>
);
})}
</div>
);
}
export default ExpandingSegments;
Technologies
| Framework | React 19 |
| Motion | Motion 12 (motion/react) |
| Styling | Tailwind CSS v4 |
| Types | TypeScript 5.9, strict |
Credits
Original interaction by @nitishkmrk. Rebuilt as a keyboard-operable radiogroup with a props API, driven by data rather than three duplicated blocks, with reduced-motion handling. The width spring and the sheen are unchanged.