component — selection

Checkbox

Accessible checkbox built on react-aria-components, styled with the quebi design system. Supports selected, indeterminate, invalid, and disabled states, plus grouping.

  • form
  • input
  • boolean
  • interactive

03 examples

States

Default, selected, indeterminate, invalid, and disabled.

Group

Several related checkboxes with CheckboxGroup.

Controlled

source

checkbox.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { Minus } from "lucide-react"
import {
  CheckboxGroup as CheckboxGroupPrimitive,
  type CheckboxGroupProps,
  Checkbox as CheckboxPrimitive,
  type CheckboxProps,
  composeRenderProps,
} from "react-aria-components"
import { cn } from "@/lib/utils"

/**
 * Checkbox — quebi design system
 *
 * Built on react-aria-components. An 18px box edged in `fg-muted`; checked
 * and indeterminate are state, so they fill with `signal` and draw the glyph
 * in `on-signal`.
 * Focus is the outward ring; invalid edges (and, checked, fills) the box in
 * `danger`.
 */
export function CheckboxGroup({ className, ...props }: CheckboxGroupProps) {
  return (
    <CheckboxGroupPrimitive
      {...props}
      data-slot="control"
      className={composeRenderProps(className, (resolved) => cn("flex flex-col gap-3", resolved))}
    />
  )
}

export function Checkbox({ className, children, ...props }: CheckboxProps) {
  return (
    <CheckboxPrimitive
      data-slot="control"
      className={composeRenderProps(className, (className) =>
        cn("group flex items-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed", className),
      )}
      {...props}
    >
      {composeRenderProps(children, (children, { isSelected, isIndeterminate, isInvalid }) => (
        <>
          <span
            data-slot="indicator"
            className={cn(
              // A box edged in `fg-muted`. Shape is what tells a reader whether
              // a group takes one answer or several, so the box only ever takes
              // the small mark radius — the radio beside it is the round one.
              "relative flex size-[18px] shrink-0 items-center justify-center rounded-(--q-radius-mark) border-(length:--q-border-control) border-quebi-fg-muted bg-transparent",
              "transition-colors duration-150",
              // Checked and indeterminate are state: `signal` fill, `on-signal` glyph.
              "group-data-[selected]:border-quebi-signal group-data-[selected]:bg-quebi-signal",
              "group-data-[indeterminate]:border-quebi-signal group-data-[indeterminate]:bg-quebi-signal",
              "group-data-[focus-visible]:ring-2 group-data-[focus-visible]:ring-quebi-focus group-data-[focus-visible]:ring-offset-2 group-data-[focus-visible]:ring-offset-quebi-bg",
              isInvalid &&
                "border-quebi-danger group-data-[focus-visible]:ring-quebi-danger/50 group-data-[selected]:border-quebi-danger group-data-[selected]:bg-quebi-danger",
            )}
          >
            {isIndeterminate ? (
              <Minus className="size-3 text-quebi-on-signal" strokeWidth={3} aria-hidden="true" />
            ) : isSelected ? (
              <span
                aria-hidden="true"
                className="block h-[9px] w-[5px] -translate-y-px rotate-45 border-quebi-on-signal border-r-2 border-b-2"
              />
            ) : null}
          </span>
          {children != null && (
            <span
              data-slot="label"
              // `min-w-0 flex-1`, so a label that wants to be a *row* can be
              // one. A facet option is "name … count", and the count is a
              // column: without this the label shrinks to its text and a
              // `w-full justify-between` inside it has nothing to justify
              // against, so every count lands right after its own label — eight
              // rows, eight x positions, and a third of each row empty to the
              // right of the number. It changes nothing for a label that is
              // only text, which is every other checkbox in the library.
              className="min-w-0 flex-1 text-sm text-quebi-fg select-none"
            >
              {children}
            </span>
          )}
        </>
      ))}
    </CheckboxPrimitive>
  )
}