Skip to content
goodtabs UI
BrowseBrowse components

Checkbox list

A bordered list of checkbox rows with a select all header and mono meta.

npx shadcn@latest add https://ui.goodtabs.co/r/checkbox-list.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/checkbox-list

1 of 4

Four rows, one disabled. The header goes indeterminate when some are checked.

With meta, controlled

2 of 3
3 vars
12 vars
12 vars

Deploying to preview, production

Mono meta on the right and a controlled value echoed below.

Without the header

email
email
slack
email

Plain rows for short lists that do not need select all.

Usage

import { CheckboxList } from "@/components/ui/checkbox-list"

<CheckboxList
  aria-label="Repositories"
  items={[{ id: "ui", label: "goodtabs/ui", meta: "12 PRs" }]}
  value={selected}
  onValueChange={setSelected}
/>

Props

PropTypeDefaultDescription
items{ id: string; label: ReactNode; meta?: ReactNode; disabled?: boolean }[]The rows. meta is mono text on the right.
valuestring[]Controlled list of checked ids.
defaultValuestring[][]Starting checked ids.
onValueChange(value: string[]) => voidFires with the full list of checked ids.
selectAllLabelstring"Select all"Label next to the header checkbox.
headerbooleantrueShow the select all row with the count.
aria-labelstringAccessible name for the group.

Source

checkbox-list/checkbox-list.tsx
"use client";

import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";

export type CheckboxListItem = {
  id: string;
  label: React.ReactNode;
  /** Mono text on the right, for example a count or a date. */
  meta?: React.ReactNode;
  disabled?: boolean;
};

type CheckboxListProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** The rows. */
  items: CheckboxListItem[];
  /** Controlled list of checked ids. */
  value?: string[];
  /** Starting checked ids for the uncontrolled form. */
  defaultValue?: string[];
  /** Fires with the full list of checked ids. */
  onValueChange?: (value: string[]) => void;
  /** Label next to the select all checkbox in the header. */
  selectAllLabel?: string;
  /** Show the header row with select all and the count. */
  header?: boolean;
  /** Accessible name for the group. */
  "aria-label"?: string;
};

const checkboxStyles =
  "data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:data-[state=indeterminate]:bg-primary data-[state=indeterminate]:[&_svg]:hidden data-[state=indeterminate]:before:absolute data-[state=indeterminate]:before:h-0.5 data-[state=indeterminate]:before:w-2 data-[state=indeterminate]:before:rounded-full data-[state=indeterminate]:before:bg-current";

/**
 * A bordered list of checkbox rows with a select all header.
 * The header checkbox goes indeterminate when only some rows are checked.
 */
export function CheckboxList({
  items,
  value,
  defaultValue = [],
  onValueChange,
  selectAllLabel = "Select all",
  header = true,
  className,
  ...props
}: CheckboxListProps) {
  const [inner, setInner] = React.useState<string[]>(defaultValue);
  const controlled = value !== undefined;
  const checked = controlled ? value : inner;
  const baseId = React.useId();

  const commit = (next: string[]) => {
    if (!controlled) setInner(next);
    onValueChange?.(next);
  };

  const enabled = items.filter((item) => !item.disabled);
  const checkedEnabled = enabled.filter((item) => checked.includes(item.id));
  const all = enabled.length > 0 && checkedEnabled.length === enabled.length;
  const some = checkedEnabled.length > 0 && !all;
  const count = items.filter((item) => checked.includes(item.id)).length;

  const toggle = (id: string, on: boolean) =>
    commit(on ? [...checked.filter((c) => c !== id), id] : checked.filter((c) => c !== id));

  const toggleAll = (on: boolean) => {
    const enabledIds = new Set(enabled.map((item) => item.id));
    const kept = checked.filter((c) => !enabledIds.has(c));
    commit(on ? [...kept, ...enabled.map((item) => item.id)] : kept);
  };

  return (
    <div
      role="group"
      data-slot="checkbox-list"
      className={cn("w-full divide-y overflow-hidden rounded-lg border bg-background", className)}
      {...props}
    >
      {header && (
        <div className="flex h-10 items-center gap-3 px-3">
          <Checkbox
            id={`${baseId}-all`}
            checked={all ? true : some ? "indeterminate" : false}
            disabled={enabled.length === 0}
            onCheckedChange={(state) => toggleAll(state === true)}
            className={checkboxStyles}
          />
          <label htmlFor={`${baseId}-all`} className="flex-1 cursor-pointer text-sm font-medium select-none">
            {selectAllLabel}
          </label>
          <span className="font-mono text-xs text-muted-foreground" aria-live="polite">
            {count} of {items.length}
          </span>
        </div>
      )}
      {items.map((item) => {
        const id = `${baseId}-${item.id}`;
        const on = checked.includes(item.id);
        return (
          <div
            key={item.id}
            data-checked={on || undefined}
            className={cn(
              "flex h-10 items-center gap-3 px-3 transition-colors duration-100 ease-out",
              !item.disabled && "hover:bg-muted/50",
            )}
          >
            <Checkbox
              id={id}
              checked={on}
              disabled={item.disabled}
              onCheckedChange={(state) => toggle(item.id, state === true)}
              className={checkboxStyles}
            />
            <label
              htmlFor={id}
              className={cn(
                "min-w-0 flex-1 truncate text-sm select-none",
                item.disabled ? "text-muted-foreground" : "cursor-pointer text-foreground",
              )}
            >
              {item.label}
            </label>
            {item.meta !== undefined && (
              <span className="shrink-0 font-mono text-xs text-muted-foreground">{item.meta}</span>
            )}
          </div>
        );
      })}
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/checkbox-list/prompt.md, or the Copy AI prompt button above.

When to use

  • Picking several items from a short list: repositories, environments, channels
  • Permission and notification settings where select all saves clicks
  • When a count of selected rows helps, it sits in the header

When not to use

  • Long lists that need search or paging, use a table with a selection column
  • Choosing exactly one item, use a radio group
  • Independent on and off settings, use switches

Agent notes

  • It takes a function (onValueChange), so the page or demo that renders it must be a client component
  • Uncontrolled by default; pass value plus onValueChange to control it. Ids must be unique and stable
  • Select all skips disabled rows and leaves their checked state as it is
  • The checked styles are set on the Checkbox from this file, so it does not depend on the primitive's data-checked variant