ToggleGroup
A row of buttons that stay pressed: one at a time, like a segmented control, or several at once.
- feature
Export niches to CSV
ai-niche-scout
- bug
Stale page after a draft is published
payload-cache
- docs
Document the cache options
payload-cache
- Open, Closed, All: one at a time. A click on the chosen one keeps it, so the filter is never empty.
- Labels switch on and off independently; any number can be on, or none.
- Tab lands on the chosen item; arrows move focus without choosing, Space or Enter chooses.
Field marks
- Import
import { ToggleGroup, ToggleGroupItem } from "cennge-ui";- Size
- 1.5 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA radio group in single mode, a toolbar of toggle buttons in multiple mode
- Keyboard
- Arrow left Arrow right HomeEndSpaceEnter
- State
- Controlled with
valueandonValueChange, or uncontrolled withdefaultValue - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Empty value
- Refused in single mode, unless
isDeselectable - Forms
- No
name: nothing is submitted - Tests
- Unit tests
When to use#
Use a ToggleGroup for a few options that change what is on screen at once: a filter, a view, a layout. Every option stays in sight and the chosen one looks pressed. In single mode a click on the chosen item keeps it, so a segmented control always has a value.
It holds state for the page, not for a form: it takes no name and sends nothing on submit. For a choice that is submitted, use RadioGroup or Checkbox.
Similar#
- Tabs switch between panels, each tab tied to its own content; a ToggleGroup only holds a value, and you decide what it changes.
- Radio is a form field with a label and a description per option, and arrows move the selection; here arrows only move focus.
- Checkbox is the form field for several independent options.
- Button runs an action once and does not stay pressed.
One value#
In single mode value is the chosen item's string. Without isDeselectable, onValueChange is never called with null: a click on the chosen item does nothing.
cennge-ui
Tactile, dark-first React components on Radix UI and Tailwind CSS v4.
import { useState } from "react";
import { ToggleGroup, ToggleGroupItem } from "cennge-ui";
const readme = "# cennge-ui\n\nTactile, dark-first React components on Radix UI and Tailwind CSS v4.";
export default function ReadmeView() {
const [view, setView] = useState<string | null>("preview");
return (
<div className="flex w-full max-w-md flex-col gap-3">
<ToggleGroup aria-label="README view" size="sm" value={view} onValueChange={setView}>
<ToggleGroupItem value="preview">Preview</ToggleGroupItem>
<ToggleGroupItem value="code">Code</ToggleGroupItem>
</ToggleGroup>
{view === "code" ? (
<pre className="rounded-medium bg-surface-3 p-4 font-mono text-[13px] whitespace-pre-wrap">{readme}</pre>
) : (
<div className="rounded-medium bg-surface-3 p-4">
<p className="text-lg font-bold">cennge-ui</p>
<p className="mt-1 text-sm text-muted">Tactile, dark-first React components on Radix UI and Tailwind CSS v4.</p>
</div>
)}
</div>
);
}Several values#
selectionMode="multiple" makes value an array of strings. Each item toggles on its own, and an empty array is a valid value.
Notify me about cennge-ui
["releases"]
import { useState } from "react";
import { ToggleGroup, ToggleGroupItem } from "cennge-ui";
export default function WatchSettings() {
const [events, setEvents] = useState<string[]>(["releases"]);
return (
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Notify me about cennge-ui</p>
<ToggleGroup selectionMode="multiple" variant="bordered" color="primary" aria-label="Notify me about cennge-ui" value={events} onValueChange={setEvents}>
<ToggleGroupItem value="issues">Issues</ToggleGroupItem>
<ToggleGroupItem value="pulls">Pull requests</ToggleGroupItem>
<ToggleGroupItem value="releases">Releases</ToggleGroupItem>
</ToggleGroup>
<p className="font-mono text-[13px] text-muted">{JSON.stringify(events)}</p>
</div>
);
}Allow an empty choice#
With isDeselectable, a click on the chosen item clears it and onValueChange gets null. Here no status means every run.
- cennge-ui e2e (webkit)Passed
- playwright-msi-scraper scrape nightlyFailed
- payload-cache unitPassed
- ai-niche-scout deploy previewPassed
import { useState } from "react";
import { ToggleGroup, ToggleGroupItem } from "cennge-ui";
const runs = [
{ name: "e2e (webkit)", repository: "cennge-ui", passed: true },
{ name: "scrape nightly", repository: "playwright-msi-scraper", passed: false },
{ name: "unit", repository: "payload-cache", passed: true },
{ name: "deploy preview", repository: "ai-niche-scout", passed: true },
];
export default function RunFilter() {
const [status, setStatus] = useState<string | null>(null);
const shown = runs.filter((run) => status === null || run.passed === (status === "passed"));
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<ToggleGroup aria-label="Run status" size="sm" isDeselectable value={status} onValueChange={setStatus}>
<ToggleGroupItem value="passed">Passed</ToggleGroupItem>
<ToggleGroupItem value="failed">Failed</ToggleGroupItem>
</ToggleGroup>
<span className="text-sm text-muted">{status === null ? "All runs" : `${shown.length} of ${runs.length} runs`}</span>
</div>
<ul className="flex flex-col gap-1.5 text-sm">
{shown.map((run) => (
<li key={run.name} className="flex justify-between gap-4">
<span className="truncate">
{run.repository} <span className="text-muted">{run.name}</span>
</span>
<span className={run.passed ? "text-success-text" : "text-danger-text"}>{run.passed ? "Passed" : "Failed"}</span>
</li>
))}
</ul>
</div>
);
}Icons only#
isIconOnly drops an item's side padding and gives it a fixed width for one icon. With no text inside, each item needs an aria-label.
- cennge-ui
- ai-niche-scout
- payload-cache
- spredo-crypto-screener
import { useState } from "react";
import { ToggleGroup, ToggleGroupItem } from "cennge-ui";
const repositories = ["cennge-ui", "ai-niche-scout", "payload-cache", "spredo-crypto-screener"];
function ListIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" strokeLinecap="round" />
</svg>
);
}
function GridIcon() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z" strokeLinejoin="round" />
</svg>
);
}
export default function RepositoryLayout() {
const [layout, setLayout] = useState<string | null>("grid");
return (
<div className="flex w-full max-w-md flex-col gap-3">
<ToggleGroup aria-label="Layout" variant="bordered" className="self-end" value={layout} onValueChange={setLayout}>
<ToggleGroupItem value="list" isIconOnly aria-label="List">
<ListIcon />
</ToggleGroupItem>
<ToggleGroupItem value="grid" isIconOnly aria-label="Grid">
<GridIcon />
</ToggleGroupItem>
</ToggleGroup>
<ul className={layout === "grid" ? "grid grid-cols-2 gap-2" : "flex flex-col gap-2"}>
{repositories.map((name) => (
<li key={name} className="truncate rounded-medium bg-surface-3 px-3 py-2 text-sm">
{name}
</li>
))}
</ul>
</div>
);
}Full width#
fullWidth stretches the group to its container and gives every item an equal share. size sets the height and the text size.
Deploy payload-cache to
import { ToggleGroup, ToggleGroupItem } from "cennge-ui";
export default function DeployTarget() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<p className="text-sm font-medium">Deploy payload-cache to</p>
<ToggleGroup aria-label="Deploy payload-cache to" color="primary" size="lg" fullWidth defaultValue="preview">
<ToggleGroupItem value="preview">Preview</ToggleGroupItem>
<ToggleGroupItem value="staging">Staging</ToggleGroupItem>
<ToggleGroupItem value="production">Production</ToggleGroupItem>
</ToggleGroup>
</div>
);
}Accessibility#
In single mode the group is a radiogroup and each item a radio with aria-checked. In multiple mode it is a toolbar and each item a button with aria-pressed. The group has no visible label of its own, so name it with aria-label.
The whole group is one Tab stop. Arrows move between items and wrap around at the ends, Home and End jump to the first and last; disabled items are skipped.
Styling#
segmented sits in a sunken track and fills the chosen item with color; bordered draws a border with dividers and tints the chosen item. className goes to the group and each item takes its own.
Props#
ToggleGroup#
ToggleGroupItem#
Also takes every attribute of <button>, except value, which mean something else here.