← All components

ToggleGroup

A segmented control whose one highlight slides, or a row of toggles that each stay down.

The idea

One thing being chosen should look like one thing moving, so single-choice is a radiogroup with a single highlight that slides between options — arrows move it, Tab leaves the group. Several things down at once cannot slide, so multiple-choice gives each pressed item its own tile, fading in place: the same surface and the same shadow, only the number of them differs.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ToggleGroup — usage17 lines
import { ToggleGroup } from "mihirmodi-ui";

// Single: one highlight that slides. Arrows move it.
<ToggleGroup
  label="Range"
  value={range}
  onChange={setRange}
  items={[
    { value: "day", children: "Day" },
    { value: "week", children: "Week" },
    { value: "month", children: "Month" },
  ]}
/>

// Multiple: each pressed item gets its own tile.
<ToggleGroup type="multiple" label="Formatting" value={marks} onChange={setMarks}
  items={[{ value: "b", label: "Bold", children: <b>B</b> }, …]} />

Props

PropNotes
type"single" | "multiple"Single takes a string value; multiple takes a string[].
items{ value, children, label?, disabled? }[]`children` may be a function of whether it is selected, so a glyph can fill.
labelstringThe group's accessible name. Required.
variant"plain" | "glass"A grey well on the page, or glass over content.
size"sm" | "md"28 or 32px items.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/actions/ToggleGroup.tsx133 lines
"use client";

import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { FLAT, SLIDE } from "../../lib/motion.js";

export type ToggleGroupItem = {
  value: string;
  /** The visible content. For an icon-only item, give `label` too. */
  children: ReactNode | ((selected: boolean) => ReactNode);
  /** The accessible name, when the content is only a glyph. */
  label?: string;
  disabled?: boolean;
};

type Single = { type?: "single"; value: string; onChange: (value: string) => void };
type Multiple = { type: "multiple"; value: string[]; onChange: (value: string[]) => void };

export type ToggleGroupProps = (Single | Multiple) & {
  items: ToggleGroupItem[];
  /** The group's accessible name. */
  label: string;
  /** `glass` floats over content, like the gallery's layout switch. `plain` sits on a grey well. */
  variant?: "plain" | "glass";
  size?: "sm" | "md";
  className?: string;
};

/**
 * A row of toggles that share one surface.
 *
 * Single-choice is a segmented control: one highlight that slides between the
 * options, because one thing being chosen should look like one thing moving,
 * not two tiles blinking. It is a radiogroup — the arrow keys move the choice
 * and Tab leaves the group, which is what a reader of radios expects.
 *
 * Multiple-choice — bold, italic, underline — cannot slide, since several can
 * be down at once, so each pressed item gets its own highlight tile instead,
 * fading in place. Same surface, same shadow; only the number of them differs.
 */
export function ToggleGroup(props: ToggleGroupProps) {
  const { items, label, variant = "plain", size = "md", className = "" } = props;
  const reduce = useReducedMotion();
  const multiple = props.type === "multiple";
  const isOn = (v: string) => (multiple ? (props.value as string[]).includes(v) : props.value === v);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
  const current = multiple ? -1 : items.findIndex((i) => i.value === props.value);

  useLayoutEffect(() => {
    const el = refs.current[current];
    setPill(el ? { x: el.offsetLeft, w: el.offsetWidth } : null);
  }, [current, items.length]);

  const press = (v: string) => {
    if (props.type === "multiple") {
      props.onChange(props.value.includes(v) ? props.value.filter((x) => x !== v) : [...props.value, v]);
    } else {
      props.onChange(v);
    }
  };

  const onKey = (e: KeyboardEvent, i: number) => {
    if (multiple) return;
    const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!step) return;
    e.preventDefault();
    for (let n = 1; n <= items.length; n++) {
      const j = (i + step * n + items.length) % items.length;
      if (!items[j].disabled) {
        press(items[j].value);
        refs.current[j]?.focus();
        return;
      }
    }
  };

  const h = size === "sm" ? "h-7 text-[12px] px-2.5 min-w-7" : "h-8 text-[13px] px-3 min-w-8";
  const tile = "rounded-[8px] bg-highlight shadow-[0_0_0_0.5px_var(--color-gray-alpha-400),0_2px_2px_rgba(0,0,0,0.04)]";
  const well =
    variant === "glass"
      ? "glass-nav dock-shadow border-[0.5px] border-gray-alpha-400 rounded-[12px]"
      : "bg-gray-alpha-100 rounded-[11px]";

  return (
    <LazyMotion features={domAnimation} strict>
      <div role={multiple ? "group" : "radiogroup"} aria-label={label} className={`relative inline-flex items-center p-[3px] ${well} ${className}`}>
        {!multiple && pill && (
          <m.span
            aria-hidden
            className={`absolute bottom-[3px] left-0 top-[3px] ${tile}`}
            initial={false}
            animate={{ x: pill.x, width: pill.w }}
            transition={reduce ? FLAT : SLIDE}
          />
        )}
        {items.map((it, i) => {
          const on = isOn(it.value);
          return (
            <button
              key={it.value}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role={multiple ? undefined : "radio"}
              aria-checked={multiple ? undefined : on}
              aria-pressed={multiple ? on : undefined}
              aria-label={it.label}
              disabled={it.disabled}
              tabIndex={multiple || on || (current < 0 && i === 0) ? 0 : -1}
              onClick={() => press(it.value)}
              onKeyDown={(e) => onKey(e, i)}
              className={`relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium leading-none transition-colors duration-150 disabled:opacity-40 ${h} ${
                on ? "text-gray-1000" : "text-quiet hover:text-gray-1000"
              }`}
            >
              {multiple && (
                <span
                  aria-hidden
                  className={`absolute inset-0 ${tile} transition-opacity duration-150 motion-reduce:transition-none ${on ? "opacity-100" : "opacity-0"}`}
                />
              )}
              <span className="relative inline-flex items-center gap-1.5">
                {typeof it.children === "function" ? it.children(on) : it.children}
              </span>
            </button>
          );
        })}
      </div>
    </LazyMotion>
  );
}
packages/ui/src/lib/motion.ts36 lines
/**
 * The motion presets the newer components share. The older ones (Dock,
 * DropMenu, HighlightList) carry their own copies of the same numbers, which is
 * deliberate: each of them documents why its curve is what it is, in place.
 */

/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;

/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;

/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;

/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;

/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;

/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
  return {
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
    animate: { opacity: 1, y: 0 },
    transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
  };
}