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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; label: ReactNode; meta?: ReactNode; disabled?: boolean }[] | The rows. meta is mono text on the right. | |
| value | string[] | Controlled list of checked ids. | |
| defaultValue | string[] | [] | Starting checked ids. |
| onValueChange | (value: string[]) => void | Fires with the full list of checked ids. | |
| selectAllLabel | string | "Select all" | Label next to the header checkbox. |
| header | boolean | true | Show the select all row with the count. |
| aria-label | string | Accessible 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