ToggleGroup
A set of toggle buttons where one or multiple can be active. Supports single selection (segmented control) and multiple selection (toolbar) modes.
Install
$
npx react-principles add toggle-group01
Features
- ✓Single & Multiple modes:
type="single"ortype="multiple" - ✓Controlled & Uncontrolled: Use
value/onValueChangeordefaultValue - ✓Variants & Sizes: default/outline in sm/md/lg
- ✓Keyboard Navigation: Arrow keys, Home/End
- ✓Flexible disabling: Group-level or per-item
02
Live Demo
Single selection (controlled)
Selected: center
Multiple selection (controlled)
Active: bold, underline
Outline variant
03
Code Snippet
ToggleGroup.tsx
import { ToggleGroup } from "@/ui/ToggleGroup"; function TextEditor() { const [value, setValue] = useState("bold,underline"); return ( <ToggleGroup type="multiple" value={value} onValueChange={setValue} variant="outline" > <ToggleGroup.Item value="bold">B</ToggleGroup.Item> <ToggleGroup.Item value="italic">I</ToggleGroup.Item> <ToggleGroup.Item value="underline">U</ToggleGroup.Item> </ToggleGroup> ); }
04
Copy-Paste (Single File)
ToggleGroup.tsx
"use client"; import { createContext, useCallback, useContext, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode, } from "react"; import { cn } from "@/shared/utils/cn"; export type ToggleGroupType = "single" | "multiple"; export type ToggleGroupVariant = "default" | "outline"; export type ToggleGroupSize = "sm" | "md" | "lg"; export interface ToggleGroupProps extends HTMLAttributes<HTMLDivElement> { type?: ToggleGroupType; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; variant?: ToggleGroupVariant; size?: ToggleGroupSize; disabled?: boolean; children: ReactNode; } export interface ToggleGroupItemProps extends ButtonHTMLAttributes<HTMLButtonElement> { value: string; disabled?: boolean; children: ReactNode; } interface ToggleGroupContextValue { value: string; variant: ToggleGroupVariant; size: ToggleGroupSize; type: ToggleGroupType; disabled: boolean; onItemChange: (itemValue: string) => void; } const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null); function useToggleGroupContext() { const ctx = useContext(ToggleGroupContext); if (!ctx) throw new Error("ToggleGroupItem must be used inside <ToggleGroup>"); return ctx; } const VARIANT_CLASSES: Record<ToggleGroupVariant, Record<"on" | "off", string>> = { default: { on: "bg-primary text-white hover:bg-primary/90 focus-visible:ring-primary/40", off: "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700 focus-visible:ring-slate-400/40", }, outline: { on: "bg-slate-100 text-slate-900 border border-slate-300 dark:bg-slate-800 dark:text-white dark:border-slate-600 focus-visible:ring-slate-400/40", off: "border border-slate-300 text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800/50 focus-visible:ring-slate-400/40", }, }; const SIZE_CLASSES: Record<ToggleGroupSize, string> = { sm: "text-xs px-3 py-1.5 h-7 gap-1.5", md: "text-sm px-4 py-2 h-9 gap-2", lg: "text-base px-6 py-2.5 h-11 gap-2", }; function parseValue(val: string): string[] { if (!val) return []; return val.split(","); } function serializeValue(arr: string[]): string { return arr.join(","); } export function ToggleGroup({ type = "single", value, defaultValue = "", onValueChange, variant = "default", size = "md", disabled = false, children, className, ...props }: ToggleGroupProps) { const [internalValue, setInternalValue] = useState(defaultValue); const isControlled = value !== undefined; const active = isControlled ? value : internalValue; const onItemChange = useCallback((itemValue: string) => { if (type === "single") { const current = parseValue(active); const next = current.includes(itemValue) && current.length === 1 ? "" : itemValue; if (!isControlled) setInternalValue(next); onValueChange?.(next); } else { const current = parseValue(active); const next = current.includes(itemValue) ? current.filter((v) => v !== itemValue) : [...current, itemValue]; const serialized = serializeValue(next); if (!isControlled) setInternalValue(serialized); onValueChange?.(serialized); } }, [type, active, isControlled, onValueChange]); return ( <ToggleGroupContext.Provider value={{ value: active, variant, size, type, disabled, onItemChange }}> <div role="group" className={cn("inline-flex items-center", className)} {...props}> {children} </div> </ToggleGroupContext.Provider> ); } ToggleGroup.Item = function ToggleGroupItem({ value: itemValue, disabled: itemDisabled = false, children, className, onClick, ...props }: ToggleGroupItemProps) { const { value, variant, size, disabled: groupDisabled, onItemChange } = useToggleGroupContext(); const buttonRef = useRef<HTMLButtonElement>(null); const isActive = parseValue(value).includes(itemValue); const isDisabled = groupDisabled || itemDisabled; const handleKeyDown = (e: React.KeyboardEvent) => { const group = buttonRef.current?.parentElement; if (!group) return; const items = Array.from( group.querySelectorAll<HTMLButtonElement>("[data-toggle-group-item]:not([disabled])") ); const currentEl = buttonRef.current; const currentIndex = currentEl ? items.indexOf(currentEl) : -1; let nextIndex = currentIndex; if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; } else if (e.key === "Home") { e.preventDefault(); nextIndex = 0; } else if (e.key === "End") { e.preventDefault(); nextIndex = items.length - 1; } if (nextIndex !== currentIndex) items[nextIndex]?.focus(); }; const stateClasses = isActive ? VARIANT_CLASSES[variant].on : VARIANT_CLASSES[variant].off; return ( <button ref={buttonRef} type="button" data-toggle-group-item aria-pressed={isActive} disabled={isDisabled} onClick={(e) => { onClick?.(e); if (isDisabled) return; onItemChange(itemValue); }} onKeyDown={handleKeyDown} className={cn( "inline-flex items-center justify-center font-semibold transition-all", "focus-visible:outline-hidden focus-visible:ring-2", "disabled:opacity-50 disabled:cursor-not-allowed", stateClasses, SIZE_CLASSES[size], "first:rounded-l-lg first:rounded-r-none last:rounded-r-lg last:rounded-l-none", "not-first:-ml-px", className, )} {...props} > {children} </button> ); };
05
Usage Examples
Single selection (segmented control)
<ToggleGroup type="single" defaultValue="center"> <ToggleGroup.Item value="left">Left</ToggleGroup.Item> <ToggleGroup.Item value="center">Center</ToggleGroup.Item> <ToggleGroup.Item value="right">Right</ToggleGroup.Item> </ToggleGroup>
Multiple selection (toolbar)
<ToggleGroup type="multiple" defaultValue="bold" variant="outline"> <ToggleGroup.Item value="bold">B</ToggleGroup.Item> <ToggleGroup.Item value="italic">I</ToggleGroup.Item> <ToggleGroup.Item value="underline">U</ToggleGroup.Item> </ToggleGroup>
Controlled with string parsing
const [value, setValue] = useState("bold,underline"); <ToggleGroup type="multiple" value={value} onValueChange={setValue} > <ToggleGroup.Item value="bold">Bold</ToggleGroup.Item> <ToggleGroup.Item value="italic">Italic</ToggleGroup.Item> <ToggleGroup.Item value="underline">Underline</ToggleGroup.Item> </ToggleGroup>
With disabled items
<ToggleGroup type="single" defaultValue="b"> <ToggleGroup.Item value="a">A</ToggleGroup.Item> <ToggleGroup.Item value="b">B</ToggleGroup.Item> <ToggleGroup.Item value="c" disabled>C</ToggleGroup.Item> </ToggleGroup>
06
Props
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| ToggleGroup | type | "single" | "multiple" | "single" | Selection mode |
| value | string | — | Controlled value (comma-separated for multiple) | |
| defaultValue | string | "" | Uncontrolled initial value | |
| onValueChange | (value: string) => void | — | Called when selection changes | |
| variant | "default" | "outline" | "default" | Visual variant | |
| size | "sm" | "md" | "lg" | "md" | Button size | |
| disabled | boolean | false | Disable all items | |
| ToggleGroupItem | value | string | — | Unique value for this item |
| disabled | boolean | false | Disable this specific item |