Expanding Segments

An icon-only row is compact but unlabelled, and labelling every icon will not fit — so people cannot tell what the options are without hovering each one.

SelectionProductivityMessagingDashboardintermediate
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/expanding-segments.json

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

bash
npm install motion

Then copy ExpandingSegments.tsx. It depends only on @/lib/cn.

Usage

tsx
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

PropTypeDefaultDescription
segmentsreadonly Segment[]—The options. Empty renders nothing.
labelstring—Names the group for assistive technology.
defaultValuestringfirst segmentUncontrolled initial selection.
valuestringuncontrolledControlled selection. Pass with onValueChange.
onValueChange(id: string) => void—Fires on every selection change.
classNamestring—Merged onto the root.

Segment: { id: string; label: string; icon: ReactNode; accentClassName?: string }.

Keyboard

KeyAction
TabEnter and leave the group — it is a single tab stop
← →Move the selection, wrapping at both ends
↑ ↓Same as left and right
Home / EndFirst 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.

ExpandingSegments.tsx
"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

FrameworkReact 19
MotionMotion 12 (motion/react)
StylingTailwind CSS v4
TypesTypeScript 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.