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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add toggle-groupWrites 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.
1import { ToggleGroup } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ToggleGroup />6 );7}Props
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
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}