UIInteractive Buttons and Controlsmotion/react

ToggleGroup

A group of pressable toggles (bold / italic / underline, alignment, or view switches) in single or multiple mode. Pressed items pop in a tinted fill with a spring, and in single mode the fill glides between items.

Preview

Customize

8 props

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add toggle-group

Writes components/ui/toggle-group.tsx and components/styles/toggle-group.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { ToggleGroup } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ToggleGroup />6  );7}

Props

PropTypeDefault
type

Allow one pressed item (pressing it again clears it) or many.

'single' | 'multiple''multiple'
preset

Built-in item set used when `items` is not provided.

'formatting' | 'alignment' | 'view''formatting'
items

Custom items; overrides the preset.

{ value: string; label: string; icon?: React.ReactNode }[]undefined
value

Controlled pressed values.

string[]undefined
defaultValue

Initially pressed values when uncontrolled.

string[][items[0].value]
onValueChange

Called with the next pressed values.

(value: string[]) => voidundefined
label

Accessible name of the group.

string'Text formatting'
variant

Floating toggles in a tray, or attached outlined segments.

'default' | 'outline''default'
size

Size preset.

'sm' | 'md' | 'lg''md'
showLabels

Show text labels next to icons.

booleanfalse
accentColor

Accent color of pressed items.

string'#6366f1'
disabled

Disable the whole group.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

role="group" labelled by `label`; each item is a native <button> with aria-pressed, and icon-only items get aria-label plus a title. Roving tabindex keeps one Tab stop: Arrow keys move focus (wrapping), Home/End jump to the ends, Enter/Space toggle. Fills are aria-hidden. Under prefers-reduced-motion the fill appears without scaling or gliding.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/toggle-group.tsx
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/toggle-group.module.css";6 7export interface ToggleGroupItem {8  /** Unique value */9  value: string;10  /** Accessible name (shown as text when there is no icon or `showLabels` is on) */11  label: string;12  /** Optional icon */13  icon?: React.ReactNode;14}15 16export interface ToggleGroupProps {17  /** Allow one pressed item or many */18  type?: "single" | "multiple";19  /** Built-in item set used when `items` is not provided */20  preset?: "formatting" | "alignment" | "view";21  /** Custom items; overrides `preset` */22  items?: ToggleGroupItem[];23  /** Controlled pressed values */24  value?: string[];25  /** Initially pressed values when uncontrolled (defaults to the first item) */26  defaultValue?: string[];27  /** Called with the next pressed values */28  onValueChange?: (value: string[]) => void;29  /** Accessible name of the group */30  label?: string;31  /** Visual style */32  variant?: "default" | "outline";33  /** Size preset */34  size?: "sm" | "md" | "lg";35  /** Show text labels next to icons */36  showLabels?: boolean;37  /** Accent color of the pressed state */38  accentColor?: string;39  /** Disable the whole group */40  disabled?: boolean;41  /** Optional className merged onto the root */42  className?: string;43  /** Optional inline styles merged onto the root */44  style?: React.CSSProperties;45}46 47function Glyph({ d }: { d: string }) {48  return (49    <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round">50      <path d={d} />51    </svg>52  );53}54 55const PRESETS: Record<NonNullable<ToggleGroupProps["preset"]>, ToggleGroupItem[]> = {56  formatting: [57    { value: "bold", label: "Bold", icon: <Glyph d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" /> },58    { value: "italic", label: "Italic", icon: <Glyph d="M19 4h-9M14 20H5M15 4 9 20" /> },59    { value: "underline", label: "Underline", icon: <Glyph d="M6 4v6a6 6 0 0 0 12 0V4M4 20h16" /> },60    { value: "strike", label: "Strikethrough", icon: <Glyph d="M16 6.5C15 5 13.5 4 11.5 4 9 4 7.5 5.5 7.5 7.5c0 4 9 3 9 8 0 2-1.8 3.5-4.5 3.5-2.2 0-4-1-5-2.5M4 12h16" /> },61  ],62  alignment: [63    { value: "left", label: "Align left", icon: <Glyph d="M4 6h16M4 12h10M4 18h14" /> },64    { value: "center", label: "Align center", icon: <Glyph d="M4 6h16M7 12h10M5 18h14" /> },65    { value: "right", label: "Align right", icon: <Glyph d="M4 6h16M10 12h10M6 18h14" /> },66    { value: "justify", label: "Justify", icon: <Glyph d="M4 6h16M4 12h16M4 18h16" /> },67  ],68  view: [69    { value: "grid", label: "Grid", icon: <Glyph d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" /> },70    { value: "list", label: "List", icon: <Glyph d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01" /> },71    { value: "board", label: "Board", icon: <Glyph d="M4 4h4v16H4zM10 4h4v10h-4zM16 4h4v13h-4z" /> },72  ],73};74 75export function ToggleGroup({76  type = "multiple",77  preset = "formatting",78  items: itemsProp,79  value: valueProp,80  defaultValue,81  onValueChange,82  label = "Text formatting",83  variant = "default",84  size = "md",85  showLabels = false,86  accentColor = "#6366f1",87  disabled = false,88  className = "",89  style,90}: ToggleGroupProps) {91  const reduce = useReducedMotion();92  const items = itemsProp && itemsProp.length > 0 ? itemsProp : PRESETS[preset] ?? PRESETS.formatting;93  const [inner, setInner] = React.useState<string[]>(defaultValue ?? [items[0].value]);94  const pressed = valueProp ?? inner;95  const firstPressed = items.findIndex((it) => pressed.includes(it.value));96  const [focusIndex, setFocusIndex] = React.useState(Math.max(0, firstPressed));97  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);98  const layoutId = React.useId();99 100  const toggle = (v: string) => {101    if (disabled) return;102    const isOn = pressed.includes(v);103    const next = type === "single" ? (isOn ? [] : [v]) : isOn ? pressed.filter((p) => p !== v) : [...pressed, v];104    if (valueProp === undefined) setInner(next);105    onValueChange?.(next);106  };107 108  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {109    const n = items.length;110    let next = -1;111    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (focusIndex + 1) % n;112    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (focusIndex - 1 + n) % n;113    else if (e.key === "Home") next = 0;114    else if (e.key === "End") next = n - 1;115    if (next < 0) return;116    e.preventDefault();117    setFocusIndex(next);118    refs.current[next]?.focus();119  };120 121  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 520, damping: 34 };122 123  return (124    <div125      role="group"126      aria-label={label}127      aria-disabled={disabled || undefined}128      onKeyDown={onKeyDown}129      className={[styles.root, styles[variant], styles[size], className].filter(Boolean).join(" ")}130      style={{ "--mf-tg-accent": accentColor, ...style } as React.CSSProperties}131    >132      {items.map((it, i) => {133        const on = pressed.includes(it.value);134        const iconOnly = !!it.icon && !showLabels;135        return (136          <button137            key={it.value}138            ref={(el) => {139              refs.current[i] = el;140            }}141            type="button"142            aria-pressed={on}143            aria-label={iconOnly ? it.label : undefined}144            title={iconOnly ? it.label : undefined}145            tabIndex={i === Math.min(focusIndex, items.length - 1) ? 0 : -1}146            disabled={disabled}147            data-state={on ? "on" : "off"}148            className={`${styles.item} ${iconOnly ? styles.iconOnly : ""}`.trim()}149            onFocus={() => setFocusIndex(i)}150            onClick={() => toggle(it.value)}151          >152            {type === "single" ? (153              on ? (154                <motion.span layoutId={layoutId} className={styles.fill} aria-hidden="true" transition={spring} />155              ) : null156            ) : (157              <AnimatePresence initial={false}>158                {on ? (159                  <motion.span160                    key="fill"161                    className={styles.fill}162                    aria-hidden="true"163                    initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}164                    animate={{ opacity: 1, scale: 1 }}165                    exit={{ opacity: 0, scale: reduce ? 1 : 0.8 }}166                    transition={spring}167                  />168                ) : null}169              </AnimatePresence>170            )}171            {it.icon ? <span className={styles.icon}>{it.icon}</span> : null}172            {iconOnly ? null : <span className={styles.text}>{it.label}</span>}173          </button>174        );175      })}176    </div>177  );178}