Buttons

Toggle Group

A row or column of independent aria-pressed toggles — one tab stop per option, optional min/max limits that refuse out loud, and per-item disabled reasons.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/registry/hooks/use-controllable-state"

/**
 * The refusal nudge. Hoisted by React 19 and deduped by href, so the component
 * stays a single file and needs no tailwind config edit.
 */
const KEYFRAMES = `@keyframes tg-refuse{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px)}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(2px)}}`

/** How long a refusal holds the status line before it falls back to the standing hint. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/toggle-group.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ToggleGroup" component: a row (or column)
of independent on/off buttons — bold + italic + underline, three map layers, the
days a job runs on. Several options can be on at once. Dependencies: lucide-react
for the tick glyph, and a useControllableState hook for the controlled /
uncontrolled duality. No animation library.

Contract
- Export a forwardRef <div> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange">.
- options: { value, label, icon?: ReactNode, disabled?: boolean,
  disabledReason?: string, hideLabel?: boolean }[].
- value?: string[] / defaultValue?: string[] / onValueChange?: (v: string[]) => void
  — controlled when `value` is passed, uncontrolled otherwise, and `onValueChange`
  fires in both modes. Selection order follows click order; the parent sorts if it
  cares. Default `defaultValue` is one module-level frozen empty array, never a
  fresh [] literal.
- min?: number, max?: number — how few / how many options may be on at once.
- maxMessage?: string, minMessage?: string — override the refusal text.
- onLimit?: (reason: "max" | "min" | "disabled", option) => void — fires on every
  turned-down click, so a group scrolled out of view can raise a toast.
- size?: "sm" | "md" | "lg" (default "md"), orientation?: "horizontal" |
  "vertical" (default "horizontal"), indicator?: "check" | "none"
  (default "check"), label?: string (default "Options") for the group name.

Behavior
- ARIA: the container is role="group" with an accessible name. Each option is a
  type="button" carrying aria-pressed — NOT aria-checked. This is the whole point
  of the component: aria-checked on a bare button is dropped by the accessibility
  tree, and radio semantics can only ever express one selection. Nothing here is
  exclusive, so there is no radiogroup and no roving tabindex.
- Tab stops: every option is its own tab stop, exactly like the separate buttons
  it represents. Space and Enter both toggle, for free, because each option is a
  real <button> — do not intercept them.
- Arrows are a convenience on top of Tab, never a replacement: ArrowRight /
  ArrowLeft (horizontal) or ArrowDown / ArrowUp (vertical) move focus along the
  group's own axis and wrap; Home / End jump to the ends. Find the focused option
  by comparing document.activeElement against a Map of value -> button element
  filled by callback refs. Chain the consumer's onKeyDown first and bail out if it
  already called preventDefault.
- Disabled options are deliberately NOT skipped by the arrows: Tab reaches them,
  so the arrows must too, or the two paths would disagree about what exists.
- Limits refuse out loud, they never produce a dead button. Derive two flags from
  the rendered value: `blocked` = off while the group is at max, `locked` = on
  while the group is at min. Both get aria-disabled (never the native disabled
  attribute — the browser blurs a node the instant it becomes disabled, and these
  states appear under the user's own finger), and deliberately no
  pointer-events:none. The guard lives in the click handler, so a blocked option
  stays hoverable, focusable, announced as unavailable, and able to explain
  itself. Pressing a disabled option surfaces its disabledReason the same way.
- Same-tick composition: two toggles can land before the parent has re-rendered
  (a double click, a controlled parent that only commits after an await). Keep the
  selection in a ref that is read AND written synchronously inside the click
  handler, and re-sync it from the rendered value in a useInsertionEffect on every
  commit. Without it the second click computes from a stale array, drops the first
  one and walks straight through `max`.
- One status line, two jobs, always polite: a role="status" aria-atomic paragraph
  that shows the standing hint ("2 of 2 selected. Turn one off to choose another.",
  "At least 1 must stay selected.") and is taken over by a refusal for 5s. Reaching
  a limit changes the availability of every other option with no visible event of
  its own, which is exactly the silent change a live region exists for. Render the
  region whenever min / max / any disabled option exists — a live region mounted
  together with its first message is usually not announced at all — and clear it on
  the timer so the next identical refusal is announced again. The group points at
  it with aria-describedby, and so does every blocked option.
- Cleanup: the refusal timer is re-armed rather than stacked (a second refusal
  restarts the window instead of letting the first timer wipe the newer message)
  and cleared on unmount.
- A successful toggle clears the pending refusal — it just answered it.

Rendering & styling
- Semantic tokens only. On: border-primary bg-primary text-primary-foreground,
  hover:bg-primary/90. Off: border-input bg-transparent text-foreground,
  hover:bg-accent hover:text-accent-foreground. Refusal text: text-destructive;
  standing hint: text-muted-foreground. Focus: focus-visible:ring-2 ring-ring with
  ring-offset-2 ring-offset-background. Merge the consumer className with cn().
- Never colour alone. The pressed state is a fill (a luminance change, not a hue
  change) plus a tick: with indicator="check" every option reserves a leading
  glyph slot and draws a Check into it while pressed, so toggling never re-flows
  the row. indicator="none" is for icon-only toolbars where the fill carries it.
- Only genuinely unavailable options fade (opacity-50): disabled options and
  options blocked by max. The last surviving selection under min keeps full
  opacity and only changes its cursor — dimming a pressed option would read as
  half-off, the opposite of what it means.
- Sizes drive height, padding, radius, gap and glyph size together
  (h-8/h-9/h-11, size-3.5/size-4/size-5). Horizontal groups are flex-wrap with a
  gap so seven weekday buttons wrap instead of overflowing; vertical groups are
  items-stretch with w-full justify-start items. Publish data-orientation on the
  root and data-state="on" | "off" on each option for consumer styling.
- Labels use min-w-0 + truncate so a long option clips instead of widening the
  column; hideLabel swaps the visible text for sr-only and keeps the accessible
  name intact (always pass an icon with it).
- Motion is decoration: a 260ms translateX nudge on a refused option, defined in a
  React 19 hoisted <style> so the component stays one file. Skip arming it when
  matchMedia("(prefers-reduced-motion: reduce)") matches — the class is cleared by
  onAnimationEnd, and an animation that never plays never fires it, so the stale
  class would outlive the refusal. Colour transitions carry
  motion-reduce:transition-none. With motion off the component is unchanged: the
  status line still explains every refusal.

Customization levers
- Limits: pass neither min nor max for a plain multi-select; pass max alone for
  "pick up to N"; pass min alone to make one option mandatory; pass both for
  "exactly N". Rewrite the wording with maxMessage / minMessage, or leave the
  inline line out entirely and route onLimit into your toast system.
- Indicator: "check" for text-labelled options (reads like a checklist), "none"
  for dense icon toolbars. Swap Check for a filled dot or a left accent bar — keep
  the slot reserved either way, or the row jumps on every toggle.
- Density: sizes are three paired entries (item classes + glyph class); add "xs"
  by adding one pair. Change the horizontal gap-1 to gap-0 plus rounded-none on
  the inner items for the attached, segmented look.
- Tokens: bg-primary fill is the assertive treatment; bg-accent
  text-accent-foreground with a border-primary outline is the quiet one, and
  var(--chart-1..5) per option is the right choice when the toggles control chart
  series and must match the line colours.
- Content: options are data, so the group renders whatever you feed it — icon
  only, label only, or both. onValueChange is the only wiring; nothing here
  persists, fetches or navigates.

Concepts

  • Pressed, not checked — every option carries aria-pressed, which is what makes it a two-state button. aria-checked on a bare <button> is silently dropped, and radio semantics could only ever express one winner; that is why this is a different widget from a segmented control rather than a variant of it.
  • One tab stop per option — the group holds no roving tabindex. These are independent controls, so Tab walks through each of them exactly as it would through four separate buttons; arrows and Home / End are added on top as a shortcut, never as the only way in.
  • A refusal that explains itself — hitting max does not disable the group, only the moves that would break it. Blocked options stay focusable and hoverable, and pressing one writes the reason into a polite live region instead of eating the click.
  • Standing hint vs. transient refusal — the same status line carries both, because reaching a limit dims other options with no visible event of its own. The hint appears the moment the limit is reached; a refusal takes the line over for five seconds and then hands it back.
  • Locked is not disabled-looking — the last selection under min keeps full opacity and only loses its cursor. Fading a pressed option would read as half-off, which is precisely the wrong signal for "this one has to stay on".
  • Same-tick composition — the click handler reads and writes the selection through a ref, so a double click composes instead of overwriting. A state-only guard is one render behind, which is exactly how a limit gets walked through.

On This Page