All patterns

Toggle Group Select

One highlight slides between grouped buttons and resizes to each label.

formsminimalfriendlyinteraction · finite · intermediate · ~0.3s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

273 lines · react + motion only
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Toggle Group Select
 *
 * One highlight for the whole group. Choosing another option slides the
 * selection across and resizes it to the new label, and the helper line
 * underneath hands over to match.
 *
 * A proper radio group: arrow keys move the choice, Home and End jump to
 * the ends, and only the selected option is in the tab order.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, so it reads correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `options`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ToggleOption = {
  /** Stable value. */
  value: string;
  /** Button label. */
  label: string;
  /** Line shown under the group while this option is chosen. */
  hint: string;
};

export type ToggleGroupSelectProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Options, in order. */
  options?: ToggleOption[];
  /** Group label. */
  label?: string;
  /** Which option starts chosen. */
  defaultValue?: string;
  /** Selection colour. */
  accent?: string;
  /** Fires with the chosen value. */
  onChange?: (value: string) => void;
};

type VariantConfig = {
  /** px the helper line travels as it hands over. */
  hintTravel: number;
  slideSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the highlight is the only thing that moves, and its
// spring sits well above a 0.8 damping ratio — a selection that
// overshoots sits on the wrong option for a frame, which is worse than
// no motion at all. Labels are text: their colour transitions, their
// size never does.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // The indicator's travel is fixed by where the segments are, so the
  // only axis here is time. Subtle spends almost none: ζ = 1.0, settled
  // in about 130ms. For settings panels full of these.
  subtle: {
    hintTravel: 2,
    slideSpring: { type: "spring", stiffness: 900, damping: 60 },
  },
  // The slide is followed easily and never waited on. ζ ≈ 0.95 — the
  // all-purpose setting.
  default: {
    hintTravel: 5,
    slideSpring: { type: "spring", stiffness: 400, damping: 38 },
  },
  // A softer glide with one gentle settle, taking a little over twice
  // as long as subtle to arrive. For a prominent choice.
  playful: {
    hintTravel: 14,
    slideSpring: { type: "spring", stiffness: 210, damping: 27 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_OPTIONS: ToggleOption[] = [
  {
    value: "monthly",
    label: "Monthly",
    hint: "Charged on the 1st. Cancel any time before renewal.",
  },
  {
    value: "quarterly",
    label: "Quarterly",
    hint: "One invoice every three months, with a 10% discount.",
  },
  {
    value: "yearly",
    label: "Yearly",
    hint: "One invoice a year, with two months included free.",
  },
];

export default function ToggleGroupSelect({
  variant = "default",
  options = DEFAULT_OPTIONS,
  label = "Billing period",
  defaultValue,
  accent = "#5B5BD6",
  onChange,
}: ToggleGroupSelectProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const groupId = useId();
  const pillId = `${groupId}-pill`;

  const [value, setValue] = useState(defaultValue ?? options[0]?.value ?? "");
  const [ring, setRing] = useState<string | null>(null);
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);

  const selectedIndex = Math.max(
    0,
    options.findIndex((option) => option.value === value)
  );
  const selected = options[selectedIndex];

  const choose = (index: number, moveFocus: boolean) => {
    const option = options[index];
    if (!option) return;
    setValue(option.value);
    onChange?.(option.value);
    if (moveFocus) buttons.current[index]?.focus();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const last = options.length - 1;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault();
      choose(selectedIndex === last ? 0 : selectedIndex + 1, true);
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault();
      choose(selectedIndex === 0 ? last : selectedIndex - 1, true);
    } else if (event.key === "Home") {
      event.preventDefault();
      choose(0, true);
    } else if (event.key === "End") {
      event.preventDefault();
      choose(last, true);
    }
  };

  return (
    <div style={{ width: 292, color: "inherit" }}>
      <span
        id={`${groupId}-label`}
        style={{
          display: "block",
          marginBottom: 7,
          fontSize: 11.5,
          fontWeight: 650,
          letterSpacing: 0.2,
          opacity: 0.6,
        }}
      >
        {label}
      </span>

      <div
        role="radiogroup"
        aria-labelledby={`${groupId}-label`}
        onKeyDown={onKeyDown}
        style={{
          display: "flex",
          gap: 3,
          padding: 3,
          borderRadius: 11,
          background: tone(7),
          border: `1px solid ${tone(12)}`,
        }}
      >
        {options.map((option, index) => {
          const isSelected = index === selectedIndex;
          return (
            <button
              key={option.value}
              ref={(node) => {
                buttons.current[index] = node;
              }}
              type="button"
              role="radio"
              aria-checked={isSelected}
              // Roving tab order: one stop for the group, arrows inside.
              tabIndex={isSelected ? 0 : -1}
              onClick={() => choose(index, false)}
              onFocus={(event) =>
                setRing(
                  event.currentTarget.matches(":focus-visible") ? option.value : null
                )
              }
              onBlur={() => setRing(null)}
              style={{
                position: "relative",
                flex: 1,
                padding: "7px 10px",
                fontFamily: "inherit",
                fontSize: 12.5,
                fontWeight: 650,
                color: isSelected ? "#fff" : "inherit",
                opacity: isSelected ? 1 : 0.62,
                background: "transparent",
                border: "none",
                borderRadius: 8,
                cursor: "pointer",
                boxShadow: ring === option.value ? `0 0 0 3px ${tone(24)}` : "none",
                outline: "none",
                // Colour settles on a CSS transition so the animation
                // loop stays transform-only.
                transition: "color 180ms ease-out, opacity 180ms ease-out",
              }}
            >
              {/* One highlight for the whole group: it is mounted inside
                  whichever option is chosen, and shared layout carries it
                  across — resizing to each label without ever touching
                  the text. */}
              {isSelected && (
                <motion.span
                  layoutId={pillId}
                  aria-hidden
                  transition={reduceMotion ? { duration: 0 } : cfg.slideSpring}
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 8,
                    background: accent,
                    zIndex: 0,
                  }}
                />
              )}
              <span style={{ position: "relative", zIndex: 1 }}>
                {option.label}
              </span>
            </button>
          );
        })}
      </div>

      <div style={{ position: "relative", height: 32, marginTop: 8 }}>
        <AnimatePresence mode="wait" initial={false}>
          <motion.p
            key={selected?.value}
            initial={
              reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.hintTravel }
            }
            animate={{ opacity: 0.55, y: 0 }}
            exit={{
              opacity: 0,
              transition: { duration: 0.1, ease: "easeIn" },
            }}
            transition={{ duration: reduceMotion ? 0.12 : 0.2, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              margin: 0,
              fontSize: 11.5,
              lineHeight: 1.4,
            }}
          >
            {selected?.hint}
          </motion.p>
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

A grouped choice with a single highlight rather than three that light up and go out. The highlight is mounted inside whichever option is chosen and shared layout carries it across, resizing to the new label without ever touching the text — labels change colour on a CSS transition so the animation loop stays transform-only. The helper line under the group hands over to match. It behaves like a real radio group: arrow keys move the choice, Home and End jump to the ends, and only the selected option sits in the tab order.

Pick one of a few optionsBilling period choiceView or density switchGrouped radio buttons

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Add a supplierTwo fields now, the rest later
    Legal name
    Ridgeline Supply Co.
    Country
    Sweden
    VAT number
    SE556031820101
    Save supplier
    Form

    The selection glides between options and resizes to each label.

Related patterns