Toggle group
A compound of 2+ Toggles that share exclusivity (type='single') or a multi-select surface (type='multiple'). Built on Radix Toggle Group. Every item shares the exact Toggle chrome via toggleVariants — variant (default / outline) and size (sm / default / lg) apply to the whole group through a Context. Two layouts: the default 'gap' shape (gap-1 between items, each rounded independently) and the Yuno-canonical 'attached' shape (items shoulder-to-shoulder with -mr-px overlap, only outer corners rounded — a segmented control).
Anatomy
One primitive with two parts. The Root owns the type (single / multiple), the shared variant/size/attached props and the group value. Each Item is a Toggle button wired to a specific value. Radix handles focus roving, ARIA and value bookkeeping.
- 1ToggleGroup (Root)
Radix Root. type='single' returns string | undefined; type='multiple' returns string[]. Owns variant, size and attached — every child Item pulls them from Context.
- 2ToggleGroupItem
One toggle button. Requires a unique value string. Reads variant/size/attached from the parent Context so you never have to pass them per-item.
- 3Layout: gap (default)
gap-1 between items, each Toggle keeps its full rounded-md and looks like a standalone chip. Best for filter chips and dense toolbars where the group should read as separate pressable buttons.
- 4Layout: attached (segmented)
gap-0 with -mr-px overlap on every item except the last, only the outer corners rounded. The 'pill divided in segments' look. Best for exclusive one-of-two/three selections (period pickers, view switchers, Live/Test).
Variants
Two selection modes × two variants × three sizes × two layouts. Pick the mode first (single vs multiple), then let the shape of the group tell the user how to read it.
Exclusive one-of-N. value is string | undefined.
<ToggleGroup type="single" defaultValue="a">
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b">B</ToggleGroupItem>
</ToggleGroup>Co-select. value is string[].
<ToggleGroup type="multiple" defaultValue={["a", "c"]}>
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b">B</ToggleGroupItem>
<ToggleGroupItem value="c">C</ToggleGroupItem>
</ToggleGroup>Default. gap-1 between items; each Toggle keeps its own rounded-md.
<ToggleGroup type="single" variant="outline" defaultValue="grid">
<ToggleGroupItem value="grid" aria-label="Grid"><GridFour /></ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List"><List /></ToggleGroupItem>
</ToggleGroup>Segmented. Items overlap borders via -mr-px; only outer corners rounded. Pass attached on the Root.
<ToggleGroup type="single" variant="outline" attached defaultValue="24h">
<ToggleGroupItem value="24h">Last 24 hours</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup><ToggleGroup size="sm" />
<ToggleGroup size="default" />
<ToggleGroup size="lg" />States
Every state on ToggleGroup is inherited from Toggle — idle / hover / pressed (data-[state=on]) / focus / disabled. In the attached layout the pressed item gets z-10 automatically so its border sits on top of its neighbours; the focus ring also goes z-10 so it never clips behind an adjacent item.
Recipes
Ready-to-copy compositions covering the most common Yuno usages of this atom.
The canonical 3+ icon-only picker for view density (grid / list / rows). type='single' + variant='outline' + defaultValue='grid'. Each Item wraps a Phosphor icon size-4 weight=light. Give each Item an aria-label so the option is discoverable without the icon.
const [value, setValue] = React.useState("grid");
<ToggleGroup
type="single"
variant="outline"
value={value}
onValueChange={(v) => v && setValue(v)}
>
<ToggleGroupItem value="grid" aria-label="Grid view"><GridFour weight="light" className="size-4" /></ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view"><List weight="light" className="size-4" /></ToggleGroupItem>
<ToggleGroupItem value="rows" aria-label="Rows view"><Rows weight="light" className="size-4" /></ToggleGroupItem>
</ToggleGroup>The Yuno canonical 'Last 24 hours / Last 7 days' filter. type='single' + variant='outline' + attached. Text-only Items. Pass a controlled value + onValueChange to fire the query when the period changes. Since it's exclusive, the group MUST always have exactly one active item — never leave it empty.
const [value, setValue] = React.useState("24h");
<ToggleGroup
type="single"
variant="outline"
attached
value={value}
onValueChange={(v) => v && setValue(v)}
>
<ToggleGroupItem value="24h">Last 24 hours</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
</ToggleGroup>A row of chips above a Table where each chip toggles an include/exclude filter. type='multiple' + variant='outline'. value is a string[], defaultValue starts with the chips that should be on. Order chips by frequency of use, not alphabetically.
const [values, setValues] = React.useState(["cc", "boleto"]);
<ToggleGroup
type="multiple"
variant="outline"
value={values}
onValueChange={setValues}
>
<ToggleGroupItem value="cc">Credit card</ToggleGroupItem>
<ToggleGroupItem value="dc">Debit card</ToggleGroupItem>
<ToggleGroupItem value="boleto">Boleto</ToggleGroupItem>
<ToggleGroupItem value="pix">PIX</ToggleGroupItem>
</ToggleGroup>The Yuno 'All / Missed' segmented control: two Items that share a fixed width and each take 50%. Wrap the group in a container with a fixed w-* and add className='[&>*]:flex-1' on the group so children stretch. Great for detail-view secondary filters.
const [value, setValue] = React.useState("all");
<ToggleGroup
type="single"
variant="outline"
attached
value={value}
onValueChange={(v) => v && setValue(v)}
className="w-40 [&>*]:flex-1"
>
<ToggleGroupItem value="all">All</ToggleGroupItem>
<ToggleGroupItem value="missed">Missed</ToggleGroupItem>
</ToggleGroup>Bold / Italic / Underline in an editor toolbar. type='multiple' (all three can be on together). Wrap each Item in a Tooltip since they're icon-only. Same Phosphor light→fill swap you'd use on a single Toggle.
const [values, setValues] = React.useState(["bold"]);
<ToggleGroup type="multiple" value={values} onValueChange={setValues}>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem value="bold" aria-label="Bold">
<TextB weight={values.includes("bold") ? "fill" : "light"} className="size-4" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>Bold</TooltipContent>
</Tooltip>
{/* italic, underline… */}
</ToggleGroup>Import
Two imports from @/components/ui/toggle-group. Both share the Toggle atom's chrome, so any change to toggleVariants flows here automatically.
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";Props
ToggleGroup wraps Radix Root and forwards every prop. The Yuno-specific surface is variant / size (from Toggle) + attached (the segmented layout). Each Item forwards Radix Item props and pulls chrome from the parent Context.
| Prop | Type | Default | Description |
|---|---|---|---|
| ToggleGroup.type | "single" | "multiple" | — | Required. 'single' → value is string | undefined (exclusive). 'multiple' → value is string[] (co-select). |
| ToggleGroup.value / defaultValue | string | string[] | — | Controlled value + defaultValue. Match the shape to type. Always seed a default — never let the group render empty. |
| ToggleGroup.onValueChange | (value: string | string[]) => void | — | Fires on every selection change. In single mode Radix can pass an empty string when the user deselects — guard with `if (v) setValue(v)` if you want to keep the current value. |
| ToggleGroup.variant | "default" | "outline" | "default" | Forwarded to every Item via Context. Same values as Toggle.variant. |
| ToggleGroup.size | "sm" | "default" | "lg" | "default" | Forwarded to every Item via Context. Same values as Toggle.size. |
| ToggleGroup.attached | boolean | false | Yuno-specific. When true, the group renders as a segmented control (gap-0 + -mr-px overlap on every item except the last, only outer corners rounded). Pressed and focus items automatically get z-10 so their border/ring stay on top. |
| ToggleGroup.disabled | boolean | false | Disables every Item. Individual disabled state is also supported via ToggleGroupItem.disabled. |
| ToggleGroup.rovingFocus | boolean | true | Radix roving focus. Left/Right cycle between items. Turn off only if you're wiring focus manually. |
| ToggleGroup.orientation | "horizontal" | "vertical" | "horizontal" | Radix orientation for arrow-key handling. Yuno uses horizontal only. |
| ToggleGroupItem.value | string | — | Required. Must be unique inside the group. Never reuse. |
| ToggleGroupItem.disabled | boolean | false | Disables this Item only. Useful when a chip depends on a feature flag. |
| ToggleGroupItem.aria-label | string | — | Required on icon-only Items. Describes the option ('Grid view', 'Bold', 'Last 24 hours'). |
| ToggleGroupItem.className | string | — | Merged onto the button via cn(). Layout tweaks only — don't override the pressed / hover surfaces. |
When to use
- Exclusive view mode / density picker (grid / list / rows).
- Period selector for a Chart / Table (Last 24 hours / Last 7 days / Last 30 days).
- Include / exclude filter chips at the top of a Table (type='multiple').
- Text formatting bar in an editor (Bold / Italic / Underline).
- Segmented one-of-two split filter ('All / Missed', 'Live / Test').
When not to use
- Navigation between panels of content — use Tabs.
- Form-field picks (radio / checkbox groups) — use RadioGroup / Checkbox, they carry the right ARIA + label semantics.
- 5+ options in a picker — Select or Command scales; a row of toggles doesn't.
- Actions that fire without state (Save, Delete) — Button.
Usage
- Always pass a defaultValue (single) or a non-empty defaultValue array (multiple) — never let the group render empty.
- Use attached + outline for exclusive segmented controls (period / view mode / Live vs Test).
- Use gap for chip-style filters where each option reads as its own pressable button.
- Give every icon-only Item an aria-label and wrap it in a Tooltip.
- Use controlled value + onValueChange whenever the group's value drives fetching or navigation.
- Don't use type='single' as a router — that's Tabs' job.
- Don't set the same value on two Items — Radix will treat them as one.
- Don't mix icon-only and text Items in the same group — the visual weight breaks apart.
- Don't rely on the initial value being empty — always seed a default so the surface never looks broken.
- Don't add gap AND attached — pick one layout per group.
Related
Cross-links to atoms and patterns you may reach for next.
- ToggleThe single-button atom this compound is built on. Reach for Toggle when you need one stateful button, ToggleGroup when you need 2+ that share exclusivity or multi-select.
- TabsFor navigating between panels of related content. If the picker CHANGES what the user sees (not just filters it), Tabs is usually the answer.
- Radio groupFor form-field exclusive picks with proper radio semantics + labels. ToggleGroup single is button-shaped; RadioGroup is form-shaped.
- CheckboxFor form-field multi-select. ToggleGroup multiple is only for chip-style filter rows above a Table.
- TooltipWraps icon-only Items so keyboard users know what each toggle does.