# Build shadcn checkbox groups in React with reliable select-all behavior

> Build a React checkbox group with select-all behavior, partial-selection feedback, labeled controls, and form-ready values.
> By Dave · 2026-10-12
> Source: https://otf-kit.dev/blog/shadcn-checkbox-groups-howto

A selection panel can look finished in a screenshot and still fail the first time someone selects only two of six options. The master control must communicate that the group is partly selected, the individual values must stay in sync, and the form must submit the choices the user actually made. This guide builds that behavior in React with labeled checkboxes, a clear select-all action, and a live count.

The keyword “shadcn checkbox” often leads builders to a styled control; a production checkbox group needs the surrounding state and form behavior too. OTF's [Checkbox component page](/components/checkbox) documents the SDK component and its indeterminate state. The implementation below keeps the parent action explicit so partial selection remains understandable even when a design system's mixed-state mark needs custom styling.

## Start with the buyer's decision

Checkboxes fit a question where a person can choose several independent answers: which columns appear in a table, which notification types arrive, or which filters stay active. They do not fit mutually exclusive choices. For one answer from a list, use radio buttons or a select. For an immediate on/off setting, a switch may be clearer.

Before writing the component, define the group in the user's language. “Choose the columns shown in this report” gives the person a useful frame; “Options” does not. Keep the group title visible, make every option label meaningful on its own, and show a short explanation when an option has a consequence that is not obvious.

A reliable group needs one source of truth for selected IDs. Avoid storing a separate `allChecked` boolean beside the selected list. Those values can drift: the list can contain three selected items while `allChecked` remains true. Instead, derive the count and all-selected state from the same selected set that drives each row.

![Byte, Dex, Luna, and Nova review a grouped checkbox list with select-all and mixed-selection states.](https://cdn.otf-kit.dev/blog/shadcn-checkbox-groups-howto/inbody1-select-all-groups-20261012b.png)

## Give the group a name and each control a label

Use a `fieldset` and `legend` when the options form one question. The legend becomes the shared context for the controls, while each checkbox gets a specific label. A user should be able to tell what each option does without relying on position, color, or an icon.

The `Checkbox` from `@otfdashkit/ui` wraps Radix Checkbox. It accepts the normal controlled checkbox props, including `checked` and `onCheckedChange`; the group state and the rule for selecting every option are still yours to implement. Keep the IDs stable and use the same ID for React keys, `htmlFor`, and the selected set.

## Derive the count and select-all behavior

This example is a view picker. The button means “select every available column,” and its pressed state means every option is selected. When only some rows are selected, the nearby status text spells out the partial state instead of making people infer it from an ambiguous mark.

```tsx
"use client";

import * as React from "react";
import { Checkbox } from "@otfdashkit/ui";

const columns = [
  { id: "customer", label: "Customer" },
  { id: "plan", label: "Plan" },
  { id: "renewal", label: "Renewal date" },
  { id: "owner", label: "Account owner" },
];

export function ColumnPicker() {
  const [selected, setSelected] = React.useState<Set<string>>(
    () => new Set(["customer", "plan"]),
  );

  const selectedCount = columns.filter(({ id }) => selected.has(id)).length;
  const allSelected = columns.length > 0 && selectedCount === columns.length;

  function setColumn(id: string, checked: boolean) {
    setSelected((current) => {
      const next = new Set(current);
      if (checked) next.add(id);
      else next.delete(id);
      return next;
    });
  }

  function toggleAll() {
    setSelected((current) => {
      const everyColumnIsSelected = columns.every(({ id }) => current.has(id));
      return everyColumnIsSelected
        ? new Set<string>()
        : new Set(columns.map(({ id }) => id));
    });
  }

  return (
    <fieldset>
      <legend>Columns shown in this report</legend>

      <button type="button" aria-pressed={allSelected} onClick={toggleAll}>
        Select all columns
      </button>
      <p role="status">
        {selectedCount} of {columns.length} columns selected
      </p>

      {columns.map(({ id, label }) => (
        <div key={id}>
          <Checkbox
            id={`column-${id}`}
            name="columns"
            value={id}
            checked={selected.has(id)}
            onCheckedChange={(checked) => setColumn(id, checked === true)}
          />
          <label htmlFor={`column-${id}`}>{label}</label>
        </div>
      ))}
    </fieldset>
  );
}
```

The selected set controls every row. Checking a child adds one ID; unchecking removes it. `toggleAll` checks the current set at click time, so clicking the action while the group is partially selected completes the selection. Clicking again clears it. The count is derived from the visible options, not copied into another state variable.

`aria-pressed` describes the button's all-selected state. It does not pretend to be a mixed checkbox. The status text announces the count as it changes, so “2 of 4 columns selected” stays clear to sighted users and assistive technology. If your interface requires a tri-state checkbox as the master control, derive its value as `false`, `true`, or `"indeterminate"` from the selected set. Then verify the mixed state visually as well as semantically. The current OTF wrapper renders a check icon inside its indicator; it does not swap that icon for a horizontal bar. Use a custom indicator or the explicit button pattern above when a bar is necessary.

![Luna and Byte review a partially selected checkbox group with an indeterminate master control.](https://cdn.otf-kit.dev/blog/shadcn-checkbox-groups-howto/inbody2-partial-selection-status-20261012b.png)

## Keep form values and application state aligned

Inside a form, give each checkbox the same `name` and a distinct `value`, as in the example. Radix renders its form input when used inside a form, so selected values can be submitted as repeated `columns` entries. If you are saving a filter outside a native form, convert the set to an array at the boundary and send that array to your state or API layer.

Decide what a change means before connecting it to persistence. A report builder might save a draft view and apply it only after the user selects “Apply.” A notification settings panel might save immediately. Do not let a checkbox silently trigger an expensive request or navigate away; the control should update the choice, while a separate action handles work that has a meaningful delay or consequence.

If the list is filtered or paginated, define what “all” covers. “Select all columns” can reasonably mean every available column. “Select all 50 results on this page” is different from selecting every matching result across hundreds of pages. Name the scope in the action and count. For disabled options, decide whether they stay selected and whether the master action skips them; compute its count from selectable options rather than assuming every row can change.

## Check keyboard, focus, and partial states

A checkbox is not complete because it can be clicked. Tab through the page and confirm each control receives a visible focus ring. Press Space to change the focused checkbox. The select-all button should also work with keyboard activation, and its pressed state must match the derived selected state. Keep the click target large enough that a label is comfortable on touch screens; the small square alone should not carry the whole interaction.

Test four states with a real list: none selected, one selected, some selected, and all selected. For each state, check the count, the master action label, and the values submitted by the form. Then toggle a child while all are selected and confirm the group returns to the partial count. Add a disabled item or filtered view if your product has one and verify the master control does not claim to select options it cannot change.

Use actual labels rather than a row of unlabeled squares. If helper copy is needed, associate it with the control or group and keep it visible. Color can reinforce checked, invalid, or disabled states, but it should not be the only signal. Check contrast and focus styling in the themes your application supports, including high-contrast and dark settings.

## Add the pattern to the app you are shipping

The group state is application behavior; the checkbox is one control inside it. Keep that boundary in mind when styling the component or moving it between pages. OTF's [Checkbox component page](/components/checkbox) shows the React SDK import and installation path. Install `@otfdashkit/ui`, use the component in the app you own, and move the same pattern into a kit when you need a fuller product starting point.

For another example of choosing an interaction based on the user's task, see [how to organize related views with React tabs](/blog/shadcn-tabs-howto). Tabs switch between one visible panel at a time; a checkbox group keeps multiple independent choices selected together. That distinction is more useful than making both controls look alike.

## Sources

- [Radix Checkbox documentation](https://www.radix-ui.com/primitives/docs/components/checkbox) — controlled state, indeterminate values, form behavior, and keyboard interaction.
- [OTF Checkbox component](/components/checkbox) — SDK installation and component example.
