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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/toggle-group.jsonPrompt
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-checkedon 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
maxdoes 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
minkeeps 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.
Button Group
A welded row or column of independent actions — 1px seams, outer corners only, an unclipped focus ring, and a measured unweld when the row stops fitting.
Export Button
An export menu for a data table — row scope, a column subset, a live size estimate on every format, and a row that generates, ticks or turns into Retry without the menu closing under it.