docs / core / checkbox

Checkbox

the collecting control. it gathers choices and waits for a submit, ticking one commits nothing until you send the form. that deferral is the whole line between it and Switch.

intensity · recedesrsc · client

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

forms, settings rows, filter groups. anywhere choices accumulateindeterminate heads a group whose children disagreenot for actions that apply immediately. that is a switch, not a checkboxnever unlabelled. the label is the click target and the accessible name

a11y

role="checkbox" named by its label · disabled sets aria-disabled · the check icon is aria-hiddenjest-axe

dependencies

@base-ui/react · class-variance-authority
live demo · try it out

You agree to our terms of service and privacy policy.

customize
labelthe click target and accessible name
descriptionhelper text under the control
sizesm for dense rows
defaultCheckedstarts ticked
indeterminatethe mixed state for a group head
disabledgreys out the whole field
usagecopy
import { Checkbox } from "usva/primitives/checkbox";

<Checkbox
  label="Accept terms"
  description="You agree to our terms of service and privacy policy."
/>

props

proptypedefaultnotes
checkedbooleancontrolled checked state.
defaultCheckedbooleanfalseinitial checked state when uncontrolled.
onCheckedChange(checked: boolean) => voidfires with the next checked state.
indeterminatebooleanfalsethe mixed state, for a parent whose children disagree.
disabledbooleanfalsedisables the whole field, label included.
labelReactNodethe field label, wired to the control by id.
descriptionReactNodehelper text under the control.
size"sm" | "md""md"sm for dense rows. the hidden hit area grows to compensate.

get it

npx shadcn add https://usva.build/r/checkbox.jsoncopy
source · components/ui/checkbox.tsxexactly what this command copiescopy
"use client";
import { Checkbox as Base } from "@base-ui/react/checkbox";
import { Field } from "@base-ui/react/field";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";

const rootVariants = cva(
  "relative flex shrink-0 items-center justify-center rounded-md border border-border bg-surface outline-none transition-control duration-base ease-soft before:absolute before:content-[''] data-[unchecked]:hover:border-border-strong data-[checked]:border-accent data-[checked]:bg-accent data-[checked]:bg-gradient-accent data-[checked]:glow-ring data-[indeterminate]:border-accent data-[indeterminate]:bg-accent data-[indeterminate]:bg-gradient-accent data-[indeterminate]:glow-ring active:scale-[0.98] motion-reduce:transition-none motion-reduce:transform-none focus-visible:border-transparent focus-visible:ring-focus aria-invalid:border-danger data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
  {
    variants: {
      size: {
        sm: "h-4 w-4 before:-inset-3.5",
        md: "h-5 w-5 before:-inset-3",
      },
    },
    defaultVariants: { size: "md" },
  },
);

const indicatorVariants = cva("text-on-accent", {
  variants: {
    size: {
      sm: "h-3 w-3",
      md: "h-3.5 w-3.5",
    },
  },
  defaultVariants: { size: "md" },
});

const descriptionVariants = cva("text-xs text-muted", {
  variants: {
    size: {
      sm: "pl-6",
      md: "pl-7",
    },
  },
  defaultVariants: { size: "md" },
});

export interface CheckboxProps
  extends Omit<
      React.ComponentPropsWithoutRef<typeof Base.Root>,
      "render" | "className"
    >,
    VariantProps<typeof rootVariants> {
  className?: string;
  label?: React.ReactNode;
  description?: React.ReactNode;
}

export const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
  ({ className, size, label, description, id, disabled, ...props }, ref) => {
    const generatedId = React.useId();
    const checkboxId = id ?? generatedId;

    return (
      <Field.Root className="flex flex-col gap-1" disabled={disabled}>
        <div className="flex items-center gap-2">
          <Base.Root
            ref={ref}
            id={checkboxId}
            disabled={disabled}
            className={cn("group", rootVariants({ size }), className)}
            {...props}
          >
            <Base.Indicator
              keepMounted
              className={cn(
                "flex items-center justify-center transition-control duration-base ease-spring motion-reduce:transition-none motion-reduce:transform-none data-[unchecked]:scale-0 data-[checked]:scale-100 data-[indeterminate]:scale-100",
              )}
            >
              <CheckIcon
                className={cn(
                  indicatorVariants({ size }),
                  "group-data-[indeterminate]:hidden",
                )}
              />
              <MinusIcon
                className={cn(
                  indicatorVariants({ size }),
                  "hidden group-data-[indeterminate]:block",
                )}
              />
            </Base.Indicator>
          </Base.Root>
          {label ? (
            <Field.Label
              htmlFor={checkboxId}
              className="text-sm text-ink select-none data-[disabled]:opacity-50"
            >
              {label}
            </Field.Label>
          ) : null}
        </div>
        {description ? (
          <Field.Description className={descriptionVariants({ size })}>
            {description}
          </Field.Description>
        ) : null}
      </Field.Root>
    );
  },
);
Checkbox.displayName = "Checkbox";

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M2.5 6.5 5 9l4.5-5.5" />
    </svg>
  );
}

function MinusIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M2.5 6h7" />
    </svg>
  );
}