# Checkbox Group

> Checkbox group with a tri-state select-all, descriptions and disabled options.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/checkbox-group
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/checkbox-group.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/checkbox-group-demo.tsx"
"use client"

import * as React from "react"

import { CheckboxGroup } from "@/components/checkbox-group"

const options = [
  { value: "comments", label: "Comments", description: "Someone replies to your thread." },
  { value: "mentions", label: "Mentions", description: "Someone @-mentions you." },
  { value: "digest", label: "Weekly digest", description: "A Monday summary of activity." },
  { value: "security", label: "Security alerts", description: "Always on, can't be turned off.", disabled: true },
]

export default function CheckboxGroupDemo() {
  const [selected, setSelected] = React.useState<string[]>(["comments", "security"])
  return (
    <div className="w-full max-w-sm rounded-2xl border bg-card p-5">
      <CheckboxGroup legend="Email notifications" options={options} value={selected} onValueChange={setSelected} selectAll />
      <p className="mt-4 text-xs text-muted-foreground" role="status">
        {selected.length} selected
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/checkbox-group.tsx"
"use client"

import * as React from "react"
import { Check, Minus } from "lucide-react"

import { cn } from "@/lib/utils"

export interface CheckboxOption {
  value: string
  label: string
  description?: string
  disabled?: boolean
}

export interface CheckboxGroupProps extends Omit<React.ComponentProps<"fieldset">, "onChange" | "defaultValue"> {
  options: CheckboxOption[]
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (value: string[]) => void
  legend?: string
  /** Show a tri-state "select all" row on top */
  selectAll?: boolean | string
}

function Box({ state }: { state: "on" | "off" | "mixed" }) {
  return (
    <span
      aria-hidden
      className={cn(
        "mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border transition-colors peer-focus-visible:ring-4 peer-focus-visible:ring-ring/40",
        state === "off" ? "border-input bg-background" : "border-primary bg-primary text-primary-foreground",
      )}
    >
      {state === "on" && <Check className="size-3" strokeWidth={3} />}
      {state === "mixed" && <Minus className="size-3" strokeWidth={3} />}
    </span>
  )
}

export function CheckboxGroup({
  options,
  value,
  defaultValue = [],
  onValueChange,
  legend,
  selectAll = false,
  className,
  ...props
}: CheckboxGroupProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const selected = value ?? internal
  const enabled = options.filter((option) => !option.disabled)
  const enabledSelected = enabled.filter((option) => selected.includes(option.value))
  const allState = enabledSelected.length === 0 ? "off" : enabledSelected.length === enabled.length ? "on" : "mixed"

  const commit = (next: string[]) => {
    setInternal(next)
    onValueChange?.(next)
  }

  const toggle = (optionValue: string) =>
    commit(selected.includes(optionValue) ? selected.filter((v) => v !== optionValue) : [...selected, optionValue])

  const toggleAll = () => {
    const locked = selected.filter((v) => options.find((o) => o.value === v)?.disabled)
    commit(allState === "on" ? locked : [...locked, ...enabled.map((o) => o.value)])
  }

  return (
    <fieldset {...props} className={cn("grid gap-3", className)}>
      {legend && <legend className="mb-1 text-sm font-medium">{legend}</legend>}
      {selectAll && (
        <label className="flex cursor-pointer items-start gap-3 border-b pb-3 text-sm font-medium">
          <input
            type="checkbox"
            className="peer sr-only"
            checked={allState === "on"}
            ref={(el) => {
              if (el) el.indeterminate = allState === "mixed"
            }}
            onChange={toggleAll}
          />
          <Box state={allState} />
          {typeof selectAll === "string" ? selectAll : "Select all"}
        </label>
      )}
      {options.map((option) => (
        <label
          key={option.value}
          className={cn("flex cursor-pointer items-start gap-3 text-sm", option.disabled && "cursor-not-allowed opacity-50")}
        >
          <input
            type="checkbox"
            className="peer sr-only"
            disabled={option.disabled}
            checked={selected.includes(option.value)}
            onChange={() => toggle(option.value)}
          />
          <Box state={selected.includes(option.value) ? "on" : "off"} />
          <span className="grid gap-0.5 leading-tight">
            <span className="font-medium">{option.label}</span>
            {option.description && <span className="text-muted-foreground">{option.description}</span>}
          </span>
        </label>
      ))}
    </fieldset>
  )
}
```
