import {
AlignCenterIcon,
AlignJustifyIcon,
AlignLeftIcon,
AlignRightIcon,
} from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
export default function ToggleButtonGroupDemo () {
return (
< ToggleButtonGroup
aria-label = "Text alignment"
defaultSelectedKeys = {[ "left" ]}
disallowEmptySelection
>
< ToggleButton id = "left" aria-label = "Align left" >
< AlignLeftIcon />
</ ToggleButton >
< ToggleButton id = "center" aria-label = "Align center" >
< AlignCenterIcon />
</ ToggleButton >
< ToggleButton id = "right" aria-label = "Align right" >
< AlignRightIcon />
</ ToggleButton >
< ToggleButton id = "justify" aria-label = "Justify" >
< AlignJustifyIcon />
</ ToggleButton >
</ ToggleButtonGroup >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/toggle-button-group
The CLI installs dependencies and any other components this one uses.
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
< ToggleButtonGroup
aria-label = "View"
defaultSelectedKeys = {[ "list" ]}
disallowEmptySelection
>
< ToggleButton id = "list" >List</ ToggleButton >
< ToggleButton id = "board" >Board</ ToggleButton >
</ ToggleButtonGroup >
Give every ToggleButton an id: that's the key used in selectedKeys. The group passes its variant and size to its buttons, so you don't repeat them on each one.
Add disallowEmptySelection for segmented controls
By default, pressing the selected button in a single-selection group
deselects it, leaving nothing selected. For view switchers, tabs-like
controls and billing toggles, set disallowEmptySelection so one option is
always active.
Toggle Button Group — a small set (2–6) of options that apply immediately, like a view switcher, a time range or text formatting.
Toggle Button — a single, independent on/off control.
Radio Group — a labelled choice in a form, especially with descriptions.
Tabs — switching between panels of content, with a tab panel for each option.
Select — more options than fit on one line.
< ToggleButtonGroup > { /* toolbar or radiogroup, passes variant + size */ }
< ToggleButton id = "…" /> { /* one per option */ }
</ ToggleButtonGroup >
Part Renders Notes ToggleButtonGroup<div role="radiogroup"> or <div role="toolbar">radiogroup for selectionMode="single", toolbar for "multiple". Nested in a React Aria Toolbar, it becomes role="group". Carries data-slot="toggle-button-group".ToggleButton<button>An option. role="radio" in single mode, aria-pressed in multiple mode. See Toggle Button .
attached (default) joins bordered buttons into one bar. segmented is a pill track with a raised selection. spaced keeps borderless buttons separate with a small gap, which suits toolbars. The group sets its buttons to outline, segmented and default respectively.
segmented Open Merged Closed
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
const variants = [ "attached" , "segmented" , "spaced" ] as const ;
export default function ToggleButtonGroupVariants () {
return (
< div className = "grid gap-5" >
{variants. map (( variant ) => (
< div key = {variant} className = "flex flex-col items-start gap-2" >
< span className = "text-muted-foreground text-xs" >{variant}</ span >
< ToggleButtonGroup
variant = {variant}
aria-label = { `Status filter (${ variant })` }
defaultSelectedKeys = {[ "open" ]}
disallowEmptySelection
>
< ToggleButton id = "open" >Open</ ToggleButton >
< ToggleButton id = "merged" >Merged</ ToggleButton >
< ToggleButton id = "closed" >Closed</ ToggleButton >
</ ToggleButtonGroup >
</ div >
))}
</ div >
);
}
A segmented control for switching views, time ranges or modes. Pair it with disallowEmptySelection so an option is always selected.
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
export default function ToggleButtonGroupSegmented () {
return (
< ToggleButtonGroup
variant = "segmented"
aria-label = "Time range"
defaultSelectedKeys = {[ "week" ]}
disallowEmptySelection
>
< ToggleButton id = "day" >Day</ ToggleButton >
< ToggleButton id = "week" >Week</ ToggleButton >
< ToggleButton id = "month" >Month</ ToggleButton >
< ToggleButton id = "year" >Year</ ToggleButton >
</ ToggleButtonGroup >
);
}
size is passed to every button, from xs (24px) to lg (40px). A size set on an individual ToggleButton wins over the group's.
1H 1D 1W 1M
1H 1D 1W 1M
1H 1D 1W 1M
1H 1D 1W 1M
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
const sizes = [ "xs" , "sm" , "md" , "lg" ] as const ;
export default function ToggleButtonGroupSizes () {
return (
< div className = "flex flex-col items-start gap-3" >
{sizes. map (( size ) => (
< ToggleButtonGroup
key = {size}
variant = "segmented"
size = {size}
aria-label = { `Chart interval (${ size })` }
defaultSelectedKeys = {[ "1d" ]}
disallowEmptySelection
>
< ToggleButton id = "1h" >1H</ ToggleButton >
< ToggleButton id = "1d" >1D</ ToggleButton >
< ToggleButton id = "1w" >1W</ ToggleButton >
< ToggleButton id = "1m" >1M</ ToggleButton >
</ ToggleButtonGroup >
))}
</ div >
);
}
Icons combine with labels in segmented controls. Icon-only buttons need an aria-label each.
import { CalendarIcon, KanbanIcon, ListIcon, TableIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
export default function ToggleButtonGroupIcons () {
return (
< div className = "flex flex-wrap items-center gap-4" >
< ToggleButtonGroup
variant = "segmented"
aria-label = "Layout"
defaultSelectedKeys = {[ "board" ]}
disallowEmptySelection
>
< ToggleButton id = "list" >
< ListIcon /> List
</ ToggleButton >
< ToggleButton id = "board" >
< KanbanIcon /> Board
</ ToggleButton >
< ToggleButton id = "calendar" >
< CalendarIcon /> Calendar
</ ToggleButton >
</ ToggleButtonGroup >
< ToggleButtonGroup
aria-label = "Layout"
defaultSelectedKeys = {[ "table" ]}
disallowEmptySelection
>
< ToggleButton id = "table" aria-label = "Table" >
< TableIcon />
</ ToggleButton >
< ToggleButton id = "board" aria-label = "Board" >
< KanbanIcon />
</ ToggleButton >
< ToggleButton id = "calendar" aria-label = "Calendar" >
< CalendarIcon />
</ ToggleButton >
</ ToggleButtonGroup >
</ div >
);
}
selectionMode="multiple" lets any number of buttons be on at once, like a formatting bar. The group is then a toolbar and each button announces aria-pressed.
import {
BoldIcon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon,
} from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
export default function ToggleButtonGroupMultiple () {
return (
< ToggleButtonGroup
variant = "spaced"
selectionMode = "multiple"
aria-label = "Text formatting"
defaultSelectedKeys = {[ "bold" , "italic" ]}
>
< ToggleButton id = "bold" aria-label = "Bold" >
< BoldIcon />
</ ToggleButton >
< ToggleButton id = "italic" aria-label = "Italic" >
< ItalicIcon />
</ ToggleButton >
< ToggleButton id = "underline" aria-label = "Underline" >
< UnderlineIcon />
</ ToggleButton >
< ToggleButton id = "strike" aria-label = "Strikethrough" >
< StrikethroughIcon />
</ ToggleButton >
</ ToggleButtonGroup >
);
}
orientation="vertical" stacks the buttons and switches keyboard navigation to ↑ / ↓ . All three variants support it.
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
export default function ToggleButtonGroupVertical () {
return (
< ToggleButtonGroup
orientation = "vertical"
aria-label = "View"
defaultSelectedKeys = {[ "list" ]}
disallowEmptySelection
>
< ToggleButton id = "list" >List</ ToggleButton >
< ToggleButton id = "board" >Board</ ToggleButton >
< ToggleButton id = "calendar" >Calendar</ ToggleButton >
</ ToggleButtonGroup >
);
}
Use selectedKeys and onSelectionChange to own the selection. The callback receives a Set of keys in both single and multiple mode. Here a weekday picker uses spaced with outline buttons set directly on each item.
M T W T F S S
Repeats every Monday, Wednesday, Friday.
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
const days = [
{ id: "mon" , label: "M" , name: "Monday" },
{ id: "tue" , label: "T" , name: "Tuesday" },
{ id: "wed" , label: "W" , name: "Wednesday" },
{ id: "thu" , label: "T" , name: "Thursday" },
{ id: "fri" , label: "F" , name: "Friday" },
{ id: "sat" , label: "S" , name: "Saturday" },
{ id: "sun" , label: "S" , name: "Sunday" },
];
export default function ToggleButtonGroupControlled () {
const [ selected , setSelected ] = useState < Set < Key >>(
new Set ([ "mon" , "wed" , "fri" ]),
);
const names = days. filter (( d ) => selected. has (d.id)). map (( d ) => d.name);
return (
< div className = "flex flex-col items-start gap-3" >
< ToggleButtonGroup
variant = "spaced"
selectionMode = "multiple"
aria-label = "Repeat on"
selectedKeys = {selected}
onSelectionChange = {setSelected}
>
{days. map (( d ) => (
< ToggleButton
key = {d.id}
id = {d.id}
variant = "outline"
aria-label = {d.name}
className = "rounded-full"
>
{d.label}
</ ToggleButton >
))}
</ ToggleButtonGroup >
< p className = "text-muted-foreground text-sm" >
{names. length > 0
? `Repeats every ${ names . join ( ", " ) }.`
: "Pick at least one day." }
</ p >
</ div >
);
}
isDisabled on the group disables every button; isDisabled on a single ToggleButton disables just that option, for example a format that isn't available on the current plan.
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
export default function ToggleButtonGroupDisabled () {
return (
< div className = "flex flex-col items-start gap-4" >
< ToggleButtonGroup
variant = "segmented"
aria-label = "Plan"
defaultSelectedKeys = {[ "pro" ]}
disallowEmptySelection
isDisabled
>
< ToggleButton id = "free" >Free</ ToggleButton >
< ToggleButton id = "pro" >Pro</ ToggleButton >
< ToggleButton id = "team" >Team</ ToggleButton >
</ ToggleButtonGroup >
< ToggleButtonGroup
aria-label = "Export format"
defaultSelectedKeys = {[ "csv" ]}
disallowEmptySelection
>
< ToggleButton id = "csv" >CSV</ ToggleButton >
< ToggleButton id = "xlsx" >Excel</ ToggleButton >
< ToggleButton id = "pdf" isDisabled >
PDF
</ ToggleButton >
</ ToggleButtonGroup >
</ div >
);
}
A small icon-only segmented control in a section header that switches a file list between grid and list layouts.
Brand guidelines.pdf 4.2 MB · Today Q3 board deck.key 18.7 MB · Yesterday Customer interviews.docx 310 KB · Mon Pricing model.xlsx 96 KB · Sep 12
import { FileTextIcon, LayoutGridIcon, ListIcon } from "lucide-react" ;
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
import { cn } from "@/lib/utils" ;
const files = [
{ name: "Brand guidelines.pdf" , size: "4.2 MB" , updated: "Today" },
{ name: "Q3 board deck.key" , size: "18.7 MB" , updated: "Yesterday" },
{ name: "Customer interviews.docx" , size: "310 KB" , updated: "Mon" },
{ name: "Pricing model.xlsx" , size: "96 KB" , updated: "Sep 12" },
];
export default function ToggleButtonGroupRecipeViewSwitcher () {
const [ view , setView ] = useState < Key >( "grid" );
return (
< div className = "w-full max-w-lg space-y-3" >
< div className = "flex items-center justify-between gap-3" >
< h3 className = "font-semibold text-sm" >Shared files</ h3 >
< ToggleButtonGroup
variant = "segmented"
size = "sm"
aria-label = "View as"
selectedKeys = {[view]}
onSelectionChange = {( keys ) => {
const [ next ] = keys;
if (next) setView (next);
}}
disallowEmptySelection
>
< ToggleButton id = "grid" aria-label = "Grid" >
< LayoutGridIcon />
</ ToggleButton >
< ToggleButton id = "list" aria-label = "List" >
< ListIcon />
</ ToggleButton >
</ ToggleButtonGroup >
</ div >
< ul
className = { cn (
view === "grid"
? "grid grid-cols-2 gap-2"
: "divide-y rounded-lg border bg-card" ,
)}
>
{files. map (( f ) => (
< li
key = {f.name}
className = { cn (
"flex items-center gap-3 text-sm" ,
view === "grid"
? "flex-col items-start rounded-lg border bg-card p-3"
: "px-3 py-2.5" ,
)}
>
< FileTextIcon className = "size-4 shrink-0 text-muted-foreground" />
< span className = "min-w-0 flex-1 truncate font-medium" >
{f.name}
</ span >
< span className = "shrink-0 text-muted-foreground text-xs" >
{f.size} · {f.updated}
</ span >
</ li >
))}
</ ul >
</ div >
);
}
Several groups inside one React Aria Toolbar: multiple selection for styles, single selection for alignment, with Tooltip s that show shortcuts. The toolbar owns arrow-key navigation across all groups, so the whole bar is one tab stop.
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
BoldIcon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
StrikethroughIcon,
UnderlineIcon,
} from "lucide-react" ;
import type { ReactNode } from "react" ;
import { Toolbar } from "react-aria-components" ;
import { Separator } from "@/components/ui/separator" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip" ;
function Tool ({
id ,
label ,
shortcut ,
children ,
} : {
id : string ;
label : string ;
shortcut ?: string ;
children : ReactNode ;
}) {
return (
< TooltipTrigger >
< ToggleButton id = {id} aria-label = {label}>
{children}
</ ToggleButton >
< Tooltip >
{label}
{shortcut && < span className = "ml-2 opacity-60" >{shortcut}</ span >}
</ Tooltip >
</ TooltipTrigger >
);
}
export default function ToggleButtonGroupRecipeEditorToolbar () {
return (
< Toolbar
aria-label = "Text formatting"
className = "flex flex-wrap items-center gap-1 rounded-lg border bg-card p-1 shadow-xs"
>
< ToggleButtonGroup
variant = "spaced"
size = "sm"
selectionMode = "multiple"
aria-label = "Style"
defaultSelectedKeys = {[ "bold" ]}
>
< Tool id = "bold" label = "Bold" shortcut = "⌘B" >
< BoldIcon />
</ Tool >
< Tool id = "italic" label = "Italic" shortcut = "⌘I" >
< ItalicIcon />
</ Tool >
< Tool id = "underline" label = "Underline" shortcut = "⌘U" >
< UnderlineIcon />
</ Tool >
< Tool id = "strike" label = "Strikethrough" shortcut = "⌘⇧X" >
< StrikethroughIcon />
</ Tool >
</ ToggleButtonGroup >
< Separator orientation = "vertical" className = "mx-1 h-5" />
< ToggleButtonGroup
variant = "spaced"
size = "sm"
aria-label = "Alignment"
defaultSelectedKeys = {[ "left" ]}
disallowEmptySelection
>
< Tool id = "left" label = "Align left" >
< AlignLeftIcon />
</ Tool >
< Tool id = "center" label = "Align center" >
< AlignCenterIcon />
</ Tool >
< Tool id = "right" label = "Align right" >
< AlignRightIcon />
</ Tool >
</ ToggleButtonGroup >
< Separator orientation = "vertical" className = "mx-1 h-5" />
< ToggleButtonGroup variant = "spaced" size = "sm" aria-label = "List" >
< Tool id = "bullets" label = "Bulleted list" >
< ListIcon />
</ Tool >
< Tool id = "numbers" label = "Numbered list" >
< ListOrderedIcon />
</ Tool >
</ ToggleButtonGroup >
</ Toolbar >
);
}
A full-width segmented control (*:flex-1) that switches a pricing card between monthly and yearly billing, with a Badge for the discount.
Monthly Yearly−20%
Pro
$16 per seat / month
Billed $192 yearly
Unlimited projects Version history SSO & audit logUpgrade to Pro
import { CheckIcon } from "lucide-react" ;
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
const features = [ "Unlimited projects" , "Version history" , "SSO & audit log" ];
export default function ToggleButtonGroupRecipePricing () {
const [ billing , setBilling ] = useState < Key >( "yearly" );
const price = billing === "yearly" ? 16 : 20 ;
return (
< div className = "w-full max-w-xs space-y-4 rounded-xl border bg-card p-5" >
< ToggleButtonGroup
variant = "segmented"
aria-label = "Billing period"
selectedKeys = {[billing]}
onSelectionChange = {( keys ) => {
const [ next ] = keys;
if (next) setBilling (next);
}}
disallowEmptySelection
className = "w-full *:flex-1"
>
< ToggleButton id = "monthly" >Monthly</ ToggleButton >
< ToggleButton id = "yearly" >
Yearly
< Badge size = "sm" color = "success" >
−20%
</ Badge >
</ ToggleButton >
</ ToggleButtonGroup >
< div >
< p className = "font-medium text-sm" >Pro</ p >
< p className = "mt-1 flex items-baseline gap-1" >
< span className = "font-semibold text-3xl tabular-nums" >${price}</ span >
< span className = "text-muted-foreground text-sm" >
per seat / month
</ span >
</ p >
< p className = "mt-1 text-muted-foreground text-xs" >
{billing === "yearly"
? `Billed $${ price * 12 } yearly`
: "Billed monthly, cancel anytime" }
</ p >
</ div >
< ul className = "space-y-2 text-sm" >
{features. map (( f ) => (
< li key = {f} className = "flex items-center gap-2" >
< CheckIcon className = "size-4 text-success" /> {f}
</ li >
))}
</ ul >
< Button className = "w-full" >Upgrade to Pro</ Button >
</ div >
);
}
In single-selection mode the group is a radiogroup and each button is a radio with aria-checked; in multiple mode it's a toolbar and each button uses aria-pressed.
The group is labelled with aria-label or aria-labelledby. Always describe what it controls ("Text alignment", "View as").
Arrow keys move focus between buttons. Tab leaves the group, and focus returns to the last focused button when you tab back in.
Inside a React Aria Toolbar, the group becomes a group and the toolbar handles arrow keys, so several groups share one tab stop.
Arrow-key direction is reversed in right-to-left locales for horizontal groups.
Key Action Tab / Shift +Tab Moves focus into or out of the group ← / → Moves focus between buttons (horizontal) ↑ / ↓ Moves focus between buttons (vertical) Space / Enter Toggles the focused button
On the ToggleButtonGroup:
Attribute Present when data-orientationAlways, "horizontal" or "vertical"; the variants use it to switch between row and column layouts data-disabledisDisabled is truedata-slot="toggle-button-group"Always
Each ToggleButton has the usual data-selected, data-hovered, data-pressed, data-focus-visible and data-disabled attributes. See Toggle Button .
className on the group styles the container. Use child selectors to style the buttons in bulk, e.g. *:flex-1 for equal-width segments or w-full for a full-width bar.
attached strips the buttons' radius and shadow and overlaps borders by 1px; the selected button is raised with z-[1] so its brand border stays visible.
To use a different item style than the group's default, set variant on each ToggleButton; it overrides the value from context.
className accepts a function of the group's state:
< ToggleButtonGroup
className = {({ orientation }) => (orientation === "vertical" ? "w-40" : "" )}
/>
Also accepts every prop of React Aria's ToggleButtonGroup .
Inside a group, id is required and isSelected, defaultSelected and onChange are ignored in favor of the group's selection. See the Toggle Button API for the rest.
Toggle Button — the individual buttons.
Radio Group — single choice in a form.
Tabs — switch between panels of content.
Button — attach plain action buttons with a React Aria Group.