Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Minus } from "lucide-react"
import { cn } from "@/lib/utils"

export interface CheckboxGroupOption {
  /** Submitted value and the row's identity. Must be unique inside `options`. */
  value: string
  label: string
  /** Second line under the label, tied to the box with `aria-describedby`. */
  description?: React.ReactNode
  /**
   * Locked. The user can never flip it, "Select all" skips it and a shift-range

Installation

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

Prompt

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.indeterminate on a genuine <input type="checkbox">, not a class that paints a dash. The platform draws it, screen readers say "mixed", and :indeterminate styling 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 max the remaining rows go aria-disabled and keep focus, hover and click; the handler refuses and the live status line says why. Native disabled would 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 options order 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; hideLegend keeps the name for assistive tech when the visual design already provides a heading.

On This Page