Toggle Group
pnpm dlx shadcn@latest add …/r/toggle-group.jsonAn accessible React toggle group for Next.js: buttons you switch on and off independently, like Bold, Italic and Underline in a toolbar.
- Default
- Outline
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
export function Formatting() {
return (
<ToggleGroup defaultValue={["bold"]} aria-label="Formatting">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
<ToggleGroupItem value="underline">Underline</ToggleGroupItem>
</ToggleGroup>
)
}"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react";
import { useId, useState } from "react";
type Size = "sm" | "md" | "lg";
const variants = [
{ variant: "default", label: "Default" },
{ variant: "outline", label: "Outline" },
] as const;
/*
* Both variants side by side, named underneath, with the size in the top-right corner. An empty row at the
* bottom, as tall as the setting's row, keeps them centred.
*/
export default function ToggleGroupVariants() {
const id = useId();
const [size, setSize] = useState<Size>("md");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-size`} className="text-sm text-muted-foreground">
Size
</label>
<Select value={size} onValueChange={(value) => setSize(value as Size)}>
<SelectTrigger id={`${id}-size`} size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm">Small</SelectItem>
<SelectItem value="md">Medium</SelectItem>
<SelectItem value="lg">Large</SelectItem>
</SelectContent>
</Select>
</div>
<ul className="flex flex-1 flex-wrap items-center justify-center gap-x-10 gap-y-6">
{variants.map(({ variant, label }) => (
<li key={variant} className="flex flex-col items-center gap-2">
<ToggleGroup
defaultValue={["bold"]}
variant={variant}
size={size}
aria-label={`Formatting, ${label.toLowerCase()} style`}
>
<ToggleGroupItem value="bold" aria-label="Bold">
<BoldIcon />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Italic">
<ItalicIcon />
</ToggleGroupItem>
<ToggleGroupItem value="underline" aria-label="Underline">
<UnderlineIcon />
</ToggleGroupItem>
</ToggleGroup>
<span className="text-xs text-muted-foreground">{label}</span>
</li>
))}
</ul>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { duration, ease, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type Ref, useContext, useLayoutEffect, useRef, useState, } from "react"; /* * Toggle Group: buttons you switch on and off independently, like Bold / Italic / Underline (owner, * 2026-10-14: split from Switcher, which is for one choice; Lab only until it's approved, so no meta yet). * - Each pressed option fades its own pill in (opacity, plus a small scale on spring.snappy) and out quickly. * - A toolbar of aria-pressed buttons (Radix Toggle Group, multiple). Arrow keys move between options; Home * and End jump to the ends. * - Flat. The pill takes the page's own colour, dark in dark mode and light in light mode (owner, * 2026-10-14). Default: a muted track. Outline: a bordered track, and the pill gets a hairline too, so it * shows on any surface. No shadows and no * scale on the buttons. Inactive options are muted and brighten on hover. * - The pill's corners follow the option's own radius (read from it). */ const toggleGroupVariants = cva("relative isolate inline-flex w-fit items-center gap-0.5 rounded-lg p-0.5", { variants: { variant: { default: "bg-muted", outline: "border border-input", }, }, defaultVariants: { variant: "default" }, }); // The options sit 2px inside the track, so the track is a control height (32, 36 or 40px) const toggleGroupItemVariants = cva( [ "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap", "text-muted-foreground transition-colors duration-(--duration-base) ease-out data-[state=off]:hover:text-foreground data-[state=on]:text-foreground", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "disabled:pointer-events-none disabled:opacity-(--opacity-disabled)", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)", ], { variants: { size: { sm: "h-7 px-2.5", md: "h-8 px-3", lg: "h-9 px-4", }, }, defaultVariants: { size: "md" }, }, ); const GroupContext = createContext<{ value: string[]; variant: "default" | "outline"; size: "sm" | "md" | "lg"; reduce: boolean; } | null>(null); type RootProps = Omit< Extract<ComponentProps<typeof ToggleGroupPrimitive.Root>, { type: "multiple" }>, "type" | "value" | "defaultValue" | "onValueChange" >; export type ToggleGroupProps = RootProps & VariantProps<typeof toggleGroupVariants> & VariantProps<typeof toggleGroupItemVariants> & { /** The options switched on, when you control them */ value?: string[]; /** The options switched on at first */ defaultValue?: string[]; /** Called with every option that's on, after each change */ onValueChange?: (value: string[]) => void; }; function ToggleGroup({ value, defaultValue, onValueChange, variant, size, className, ...props }: ToggleGroupProps) { const reduce = useReducedMotion() ?? false; // Mirror the value, controlled or not, so options know whether they're on const [internal, setInternal] = useState<string[]>(defaultValue ?? []); const current = value ?? internal; return ( <GroupContext.Provider value={{ value: current, variant: variant ?? "default", size: size ?? "md", reduce }} > <ToggleGroupPrimitive.Root data-slot="toggle-group" data-variant={variant ?? "default"} type="multiple" value={current} onValueChange={(next) => { setInternal(next); onValueChange?.(next); }} className={cn(toggleGroupVariants({ variant }), className)} {...props} /> </GroupContext.Provider> ); } /** Hands the option's node to our ref and to the caller's */ function useMergedRef<T>(own: { current: T | null }, theirs?: Ref<T>) { return (node: T | null) => { own.current = node; if (typeof theirs === "function") theirs(node); else if (theirs) (theirs as { current: T | null }).current = node; }; } function ToggleGroupItem({ className, children, value, ref, ...props }: ComponentProps<typeof ToggleGroupPrimitive.Item>) { const group = useContext(GroupContext); const node = useRef<HTMLButtonElement>(null); const setRef = useMergedRef(node, ref); const [radius, setRadius] = useState(0); // The pill takes the option's corner radius, whatever the user's --radius is useLayoutEffect(() => { if (node.current) setRadius(Number.parseFloat(getComputedStyle(node.current).borderTopLeftRadius) || 0); }, []); const on = group?.value.includes(value) ?? false; const reduce = group?.reduce ?? false; return ( <ToggleGroupPrimitive.Item ref={setRef} data-slot="toggle-group-item" value={value} className={cn(toggleGroupItemVariants({ size: group?.size }), className)} {...props} > <AnimatePresence initial={false}> {on ? ( <motion.span key="pill" aria-hidden className={cn( "absolute inset-0 -z-10", group?.variant === "outline" ? "border border-input bg-background" : "bg-background", )} style={{ borderRadius: radius }} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92 }} animate={{ opacity: 1, scale: 1 }} exit={ reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92, transition: { duration: duration.fast, ease: ease.in } } } transition={reduce ? { duration: duration.fast } : spring.snappy} /> ) : null} </AnimatePresence> {children} </ToggleGroupPrimitive.Item> ); } export { ToggleGroup, ToggleGroupItem, toggleGroupItemVariants, toggleGroupVariants };Update the import paths to match your project.
Examples
Days
Text options work the same way, here picking the days to repeat on.
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export default function ToggleGroupDays() {
return (
<ToggleGroup defaultValue={["mon", "wed", "fri"]} aria-label="Repeat on">
{days.map((day) => (
<ToggleGroupItem key={day} value={day.toLowerCase()}>
{day}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}A disabled option
Disabled options don't respond and the arrow keys skip them.
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export default function ToggleGroupDisabled() {
return (
<ToggleGroup defaultValue={["email"]} variant="outline" aria-label="Notify by">
<ToggleGroupItem value="email">Email</ToggleGroupItem>
<ToggleGroupItem value="sms" disabled>
SMS
</ToggleGroupItem>
<ToggleGroupItem value="push">Push</ToggleGroupItem>
</ToggleGroup>
);
}API reference
ToggleGroup
Options switched on and off independently: a toolbar of pressed buttons, built on Radix Toggle Group. Arrow keys move between options; Home and End jump to the ends. For one choice, use Switcher. Give it a name with aria-label.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | string[] | – | The options switched on, when you control them. |
defaultValue? | string[] | – | The options switched on at first. |
onValueChange? | (value: string[]) => void | – | Called with every option that's on, after each change. |
variant? | "default" | "outline" | "default" | Default: a muted track. Outline: a bordered track, with a bordered pill. The pill is dark in dark mode and light in light mode. |
size? | "sm" | "md" | "lg" | "md" | 32, 36 or 40px tall, matching Button. |
disabled? | boolean | false | Disables every option. |
ToggleGroupItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The value this option adds when it's on. |
disabled? | boolean | false | Skips the option. |
Related components
An accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.
- Ghost
- Outline
- With text
An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".