Skip to content
cennge-ui
GitHubv0.3.1, changelog

ToggleGroup

A row of buttons that stay pressed: one at a time, like a segmented control, or several at once.

  • Export niches to CSV

    ai-niche-scout

    feature
  • Stale page after a draft is published

    payload-cache

    bug
  • Document the cache options

    payload-cache

    docs
  1. Open, Closed, All: one at a time. A click on the chosen one keeps it, so the filter is never empty.
  2. Labels switch on and off independently; any number can be on, or none.
  3. Tab lands on the chosen item; arrows move focus without choosing, Space or Enter chooses.
Live: the component itself, not a picture of it.

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 value and onValueChange, or uncontrolled with defaultValue
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.

readme-view.tsx
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"]

notifications.tsx
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.

All runs
  • cennge-ui e2e (webkit)Passed
  • playwright-msi-scraper scrape nightlyFailed
  • payload-cache unitPassed
  • ai-niche-scout deploy previewPassed
deselectable.tsx
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
icons.tsx
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

full-width.tsx
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#

PropTypeDefault
selectionMode
"multiple" | "single"
"single"
value
string[] | string | null
–
defaultValue
string[] | string | null
–
onValueChange
(value: string[]) => void | (value: string | null) => void
–
variant
"bordered""segmented"
"segmented"
color
"default""primary""secondary""success""warning""danger""info"Colour of the selected items.
"default"
size
"sm""md""lg"
"md"
fullWidth
boolean
–
isDisabled
boolean
–
orientation
"horizontal""vertical"
"horizontal"
className
string
–
children
React.ReactNode
–
aria-label
stringName the group for screen readers.
–
isDeselectable
booleanAllow clicking the selected item to clear the selection. Off by default, so a segmented control always has a value.Only with selectionMode="single"
false

ToggleGroupItem#

PropTypeDefault
valuerequired
string
–
isDisabled
boolean
–
isIconOnly
booleanSquare item for a single icon. Give it an aria-label.
–

Also takes every attribute of <button>, except value, which mean something else here.