Checkbox Group
A flat fieldset of checkboxes with a genuinely indeterminate select-all, per-row descriptions, shift-range selection, and min/max limits that answer with a message instead of a dead control.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/checkbox-group.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CheckboxGroup" component. Dependencies:
lucide-react (Check, Minus) and a cn() class merger. No form library, no Radix —
the boxes are real <input type="checkbox"> elements, because the whole point is
that the mixed state is the platform's `indeterminate` property.
Contract
- forwardRef<HTMLFieldSetElement>, props extend
Omit<React.FieldsetHTMLAttributes<HTMLFieldSetElement>,
"defaultValue" | "disabled" | "name" | "onChange">.
`disabled` is omitted on purpose: the native fieldset `disabled` attribute
blurs whichever box inside it currently has focus. Lock rows individually.
- options: { value: string; label: string; description?: ReactNode;
disabled?: boolean }[]. `value` is both the identity of the row and what it
submits, so it must be unique.
- Selection is controlled OR uncontrolled from one code path:
value?: string[], defaultValue?: string[] (read once, on mount),
onValueChange?: (value: string[]) => void.
- legend: string (required), hideLegend?: boolean (sr-only), hint?: ReactNode,
name?: string (native form name on every box), min?: number, max?: number,
selectAll?: boolean, selectAllLabel?: string (default "Select all"),
columns?: 1 | 2 (default 1), error?: ReactNode.
Behavior
- Normalise once per render:
chosen = options.filter(o => new Set(value).has(o.value)).map(o => o.value).
Everything downstream reads `chosen`, so the emitted array is always in
options order and ids that no longer exist in `options` are dropped instead of
resurrecting later.
- Derived counts: total = options.length; allChecked = total > 0 &&
chosen.length === total; someChecked = chosen.length > 0 && !allChecked;
flippable = options.filter(o => !o.disabled).length;
atMax = max !== undefined && chosen.length >= max.
- Select all writes the DOM property, never a class: keep a ref to that input and
re-assert `el.indeterminate = someChecked` in a layout effect WITH NO
dependency array. Reason: clicking a checkbox makes the browser clear
`indeterminate` itself, and the mixed state frequently survives the click (a
locked row keeps the group partial), so a dependency-keyed effect would never
put the dash back. Also re-assert it synchronously at the top of the select-all
handler, because a click that changes nothing never triggers a commit.
Use useLayoutEffect on the client and useEffect on the server — React warns
about layout effects during SSR.
- Select all fills while anything is still addable and only clears when nothing
is: `fill = options.some(o => !o.disabled && !chosen.includes(o.value))`. One
click on a mixed box completes the group, the next empties it. Locked rows are
never touched, which is why a group holding a locked-off row can never reach
"all" and honestly stays mixed.
- The select-all row is not rendered at all when `max < options.length` — a
select-all that cannot reach "all" is a lie — nor when options is empty.
- Shift-range: keep an anchor ref holding the value of the last row the user
really flipped. In the change handler READ the anchor and WRITE the new one in
the same synchronous block, so two clicks in one tick cannot both range-select
from the same stale row. React's onChange for a checkbox is really the
browser's click, so read the modifier off event.nativeEvent — narrow it with
`instanceof MouseEvent`, since the ChangeEvent type declares no modifiers and
keyboard activation dispatches a click too. With shift held and a live anchor,
walk options[min(anchor,i) .. max(anchor,i)] and apply the clicked row's NEW
state to every unlocked row in it. An anchor whose row has left `options` is
simply not an anchor. A select-all clears the anchor.
- Limits are messages, not dead controls:
* At `max`, every unchecked unlocked row gets aria-disabled="true" — and NOT
the native disabled attribute, and no pointer-events:none. It stays
focusable, hoverable and clickable; the handler refuses the check and sets a
refusal string ("You can select at most N. Clear one to swap."). A range that
overflows fills up to the ceiling and reports the remainder instead of
dropping the whole range. Checked rows stay toggleable, so a swap is always
one click away.
* Show the refusal only while atMax is still true, so raising `max` from
outside retires it without an effect chasing it.
* `min` never blocks an uncheck. It only reports, and only after the first
interaction (a `touched` flag), so an untouched form is not pre-scolded.
- A refused click emits nothing: rebuild the next array, compare it element-wise
with `chosen`, and return before calling onValueChange when they match.
- Locked rows: return from the handler before touching state. The input is
controlled, so React restores the DOM box by itself.
- Status line priority: consumer `error` > max refusal > min shortfall >
"N of M selected" (M = max ?? total, or "No options available." when the list
is empty). The first three paint text-destructive, the last
text-muted-foreground.
Rendering & styling
- Root is a real <fieldset> (add min-w-0: a fieldset defaults to
min-width:min-content and refuses to shrink in flex/grid parents) with a
<legend> as its first child. There is no ARIA substitute worth using here —
fieldset+legend already names the group.
- ARIA: the fieldset carries aria-describedby pointing at the hint id AND the
status id, merged with any aria-describedby the consumer passed rather than
overwriting it. Each box gets aria-describedby for its own description. The
select-all box gets aria-controls listing every option id. Do NOT put
aria-invalid on the fieldset — role=group does not support it; the destructive
status line is the invalidity signal. Build ids from useId() plus the row
INDEX, never the row value: aria-controls is a space-separated IDREF list and
a value containing a space would break it.
- The status <p> is aria-live="polite" aria-atomic="true" and is mounted from
the first render (it always has counter text), because a live region that
appears together with its message is announced by nobody.
- Each row is a <label htmlFor> wrapping the box and the text, so the
description is part of the hit target.
- Semantic tokens only: border-input + bg-background for the box,
checked:bg-primary / checked:border-primary and
indeterminate:bg-primary / indeterminate:border-primary, text-primary-foreground
for the Check and Minus glyphs, text-muted-foreground for hints, descriptions
and the counter, text-destructive for problems, ring-ring with
ring-offset-background for focus-visible. The tick and the dash are absolutely
positioned siblings after the input, revealed with peer-checked:opacity-100 and
peer-indeterminate:opacity-100.
- aria-disabled styling instead of :disabled — aria-disabled:opacity-50 and
aria-disabled:cursor-not-allowed on the box, opacity-60 on the label text.
- columns={2} uses a CONTAINER query, not a viewport breakpoint: put
@container/checkbox-group on the wrapper and @lg/checkbox-group:grid-cols-2 on
the list, so the same group is two columns in a page and one column in a 320px
sidebar.
- Motion is decorative only: transition-colors with motion-reduce:transition-none.
Nothing about selecting, refusing or announcing depends on it.
- Cleanup: the component owns no timer, rAF, listener or observer. The only
imperative DOM write is `indeterminate`, which lives on a node React unmounts.
Customization levers
- Density: gap-y-3 between rows and gap-x-6 between columns; drop the
description slot and the group compresses to a plain list.
- Split threshold: swap @lg (32rem) for @md (28rem) for denser lists, or add
columns={3} as one more variant entry — the grid is the only thing that reads it.
- Which sub-blocks exist: legend (hideLegend for a sr-only name), hint,
select-all row, per-row description, status line. Everything but the legend and
the list is optional.
- Limits: keep min/max for reporting only, or hand the same numbers to your zod
schema — the component never mutates the value to satisfy them, so both stay
in agreement.
- Tokens: primary drives the checked fill; switch to bg-foreground /
text-background for a monochrome form, or point the box border at
border-destructive while `error` is set for a louder invalid state.
- Messages: every string ("Select all", the refusal, the shortfall, the counter)
is either a prop or one template literal — replace them with your i18n lookup.
- Submission: pass `name` and the group posts repeated name=value pairs with a
plain <form>; omit it and the group is purely controlled state.Concepts
- Real indeterminate — the mixed state is
element.indeterminateon a genuine<input type="checkbox">, not a class that paints a dash. The platform draws it, screen readers say "mixed", and:indeterminatestyling is honest — which also means the browser clears it on every click, so the property is re-asserted after each commit. - A limit is a sentence, not a dead control — at
maxthe remaining rows goaria-disabledand keep focus, hover and click; the handler refuses and the live status line says why. Nativedisabledwould blur whatever the user was standing on the instant the ceiling was hit. - Anchor-based shift range — the last row the user really flipped is the far end of the next shift-click, read and rewritten inside one synchronous block so a double click cannot range-select from a stale anchor; an anchor whose row has left the list simply stops being one.
- Normalised emit — the value handed back is always rebuilt in
optionsorder with unknown ids dropped, and a click the guard swallowed emits nothing at all, so "the consumer heard a change" and "something changed" never diverge. - Locked means locked, not absent — a disabled row keeps whatever the value says, is skipped by select-all and by ranges, and still submits when it is checked: "you cannot change this" rather than "this does not exist".
- Fieldset and legend do the naming — the group's accessible name is the legend, its description is the hint plus the live status line, and no ARIA wrapper is invented on top;
hideLegendkeeps the name for assistive tech when the visual design already provides a heading.
Multi Select
A multi-selection field: removable chips with a +N collapse in the trigger, a searchable checkbox listbox, select-all / clear, group headings, and a cap that refuses out loud.
Assignee Picker
A people picker with searchable presence-aware rows, a first-class Unassigned option, a recently-assigned shortcut group, single or multi assignment, blocked rows that say why, and four data states.