Checkbox

A control that lets the user toggle between checked and not checked.

By clicking this checkbox, you agree to the terms.
import * as stylex from "@stylexjs/stylex"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { HStack, VStack } from "@/components/ui/stack"
import { color, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  group: { width: "100%", maxWidth: "24rem" },
  description: {
    color: color.mutedForeground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 400,
  },
})

export default function CheckboxDemo() {
  return (
    <VStack gap={6} xstyle={styles.group}>
      <HStack gap={2} align="center">
        <Checkbox id="terms-checkbox" name="terms-checkbox" />
        <Label htmlFor="terms-checkbox">Accept terms and conditions</Label>
      </HStack>
      <HStack gap={2} align="start">
        <Checkbox id="terms-checkbox-2" name="terms-checkbox-2" defaultChecked />
        <VStack gap={1}>
          <Label htmlFor="terms-checkbox-2">Accept terms and conditions</Label>
          <span {...stylex.props(styles.description)}>
            By clicking this checkbox, you agree to the terms.
          </span>
        </VStack>
      </HStack>
      <HStack gap={2} align="center">
        <Checkbox id="toggle-checkbox" name="toggle-checkbox" disabled />
        <Label htmlFor="toggle-checkbox" disabled>
          Enable notifications
        </Label>
      </HStack>
      <Label>
        <Checkbox name="toggle-checkbox-2" />
        <VStack gap={1}>
          <span>Enable notifications</span>
          <span {...stylex.props(styles.description)}>
            You can enable or disable notifications at any time.
          </span>
        </VStack>
      </Label>
    </VStack>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. The checkbox imports both. It also needs interop.css, which sizes the tick. The examples caption it with a Label.

Paste field-state.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/checkbox.tsx
"use client"

import { use, type CSSProperties } from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { Check, Minus } from "lucide-react"

import { stateStyles } from "@/lib/state-styles"
import { FieldStateContext } from "@/lib/field-state"
import { states } from "@/theme/states"
import { color, duration, ease, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: {
    position: "relative",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    flexShrink: 0,
    width: space.s4,
    height: space.s4,
    borderRadius: radius.sm,
    borderWidth: 1,
    borderStyle: "solid",
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "--carbon-icon-size": space.s3_5,
    "::after": {
      content: '""',
      position: "absolute",
      insetInline: `calc(-1 * ${space.s3})`,
      insetBlock: `calc(-1 * ${space.s2})`,
    },
  },
  checked: {
    "--carbon-border-color": color.primary,
    backgroundColor: color.primary,
    color: color.primaryForeground,
  },
  indicator: {
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: duration.color,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  indicatorHidden: { opacity: 0, transform: "scale(0.6)" },
})

const checkboxStyles = {
  root: styles.root,
  choice: states.keyChoice,
  checked: styles.checked,
  disabled: states.disabled,
  invalid: states.invalidRing,
}

type CheckboxProps = Omit<CheckboxPrimitive.Root.Props, "className" | "style"> & {
  xstyle?: StyleXStyles
  style?: CSSProperties
  className?: string
}

function Checkbox({ xstyle, style, className, indeterminate, ...props }: CheckboxProps) {
  const fieldState = use(FieldStateContext)
  return (
    <CheckboxPrimitive.Root
      data-slot="checkbox"
      indeterminate={indeterminate}
      {...props}
      {...stateStyles(
        (state) => [
          styles.root,
          states.keyChoice,
          states.focusRing,
          (state.valid === false ||
            fieldState?.valid === false ||
            props["aria-invalid"] === true ||
            props["aria-invalid"] === "true") &&
            states.invalidRing,
          (state.checked || state.indeterminate) && styles.checked,
          state.disabled && states.disabled,
          xstyle,
        ],
        { className, style, hook: "carbon-icons" },
      )}
    >
      <CheckboxPrimitive.Indicator
        data-slot="checkbox-indicator"
        {...stateStyles((state) => [
          styles.indicator,
          (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
            styles.indicatorHidden,
        ])}
        render={(indicatorProps, state) => (
          <span {...indicatorProps}>{state.indeterminate ? <Minus /> : <Check />}</span>
        )}
      />
    </CheckboxPrimitive.Root>
  )
}

export { Checkbox, checkboxStyles }

Update the import paths to match your project setup.

Usage

import { Checkbox } from "@/components/ui/checkbox"
<Checkbox id="terms" />
<Label htmlFor="terms">Accept terms and conditions</Label>

Lay a checkbox and its label out in an HStack, or in a horizontal Field for automatic label association.

Checked state

Use defaultChecked for an uncontrolled checkbox, or checked and onCheckedChange to control it.

import { useState } from "react"

export function Example() {
  const [checked, setChecked] = useState(false)

  return <Checkbox checked={checked} onCheckedChange={setChecked} />
}

Invalid state

Set invalid on Field or aria-invalid on a bare checkbox. Both show the destructive border and ring.

import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { Label } from "@/components/ui/label"
import { VStack } from "@/components/ui/stack"

export default function CheckboxInvalid() {
  return (
    <VStack gap={5} width="100%" maxWidth="24rem">
      <VStack gap={2}>
        <Label htmlFor="checkbox-invalid-bare">Accept terms and conditions</Label>
        <Checkbox
          id="checkbox-invalid-bare"
          aria-invalid
          aria-describedby="checkbox-invalid-bare-error"
        />
        <FieldError id="checkbox-invalid-bare-error">
          You must accept the terms to continue.
        </FieldError>
      </VStack>
      <Field invalid>
        <FieldLabel>Accept terms and conditions</FieldLabel>
        <Checkbox />
        <FieldError>You must accept the terms to continue.</FieldError>
      </Field>
    </VStack>
  )
}

Basic

Pair the checkbox with a Label in an HStack.

import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { HStack } from "@/components/ui/stack"

export default function CheckboxBasic() {
  return (
    <HStack gap={2} align="center">
      <Checkbox id="terms-checkbox-basic" name="terms-checkbox-basic" />
      <Label htmlFor="terms-checkbox-basic">Accept terms and conditions</Label>
    </HStack>
  )
}

Description

Stack the label and a line of helper text in a VStack beside the checkbox, and align the row to start so the box sits on the first line.

By clicking this checkbox, you agree to the terms and conditions.
import * as stylex from "@stylexjs/stylex"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { HStack, VStack } from "@/components/ui/stack"
import { color, leading, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "18rem" },
  description: { color: color.mutedForeground, fontSize: text.sm, lineHeight: leading.sm },
})

export default function CheckboxDescription() {
  return (
    <HStack gap={2} align="start" xstyle={styles.field}>
      <Checkbox id="terms-checkbox-desc" name="terms-checkbox-desc" defaultChecked />
      <VStack gap={1}>
        <Label htmlFor="terms-checkbox-desc">Accept terms and conditions</Label>
        <span {...stylex.props(styles.description)}>
          By clicking this checkbox, you agree to the terms and conditions.
        </span>
      </VStack>
    </HStack>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field"
export default function CheckboxDisabled() {
  return (
    <Field orientation="horizontal" disabled>
      <Checkbox name="notifications" />
      <FieldLabel>Enable notifications</FieldLabel>
    </Field>
  )
}

Group

Use Checkbox Group to share selection state and Field validation.

Show these items on the desktop:

Select the items you want to show on the desktop.

import { Checkbox } from "@/components/ui/checkbox"
import { CheckboxGroup } from "@/components/ui/checkbox-group"
import { Field, FieldDescription, FieldItem, FieldLabel } from "@/components/ui/field"
const items = [
  { value: "hard-disks", label: "Hard disks" },
  { value: "external-disks", label: "External disks" },
  { value: "cds-dvds", label: "CDs, DVDs, and iPods" },
  { value: "connected-servers", label: "Connected servers" },
]
export default function CheckboxGroupExample() {
  return (
    <Field name="desktop-items">
      <FieldLabel nativeLabel={false} render={<div />}>
        Show these items on the desktop:
      </FieldLabel>
      <FieldDescription>Select the items you want to show on the desktop.</FieldDescription>
      <CheckboxGroup defaultValue={["hard-disks", "external-disks"]}>
        {items.map(({ value, label }) => (
          <FieldItem key={value}>
            <FieldLabel>
              <Checkbox value={value} />
              {label}
            </FieldLabel>
          </FieldItem>
        ))}
      </CheckboxGroup>
    </Field>
  )
}

Indeterminate

Use parent inside a controlled Checkbox Group with allValues to derive the checked and indeterminate states automatically.

Use the indeterminate prop for a parent that controls a set of children. It draws a dash in place of the tick and is independent of checked.

"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { HStack, VStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  children: { paddingInlineStart: space.s6 },
})

const permissions = [
  { id: "read", label: "Read" },
  { id: "write", label: "Write" },
  { id: "deploy", label: "Deploy" },
]

export default function CheckboxIndeterminate() {
  const [granted, setGranted] = useState<string[]>(["read"])

  const all = granted.length === permissions.length
  const some = granted.length > 0 && !all

  return (
    <VStack gap={3}>
      <HStack gap={2} align="center">
        <Checkbox
          id="permissions-all"
          checked={all}
          indeterminate={some}
          onCheckedChange={(checked) => setGranted(checked ? permissions.map((p) => p.id) : [])}
        />
        <Label htmlFor="permissions-all">All permissions</Label>
      </HStack>
      <VStack gap={3} xstyle={styles.children}>
        {permissions.map((permission) => (
          <HStack key={permission.id} gap={2} align="center">
            <Checkbox
              id={`permissions-${permission.id}`}
              checked={granted.includes(permission.id)}
              onCheckedChange={(checked) =>
                setGranted((current) =>
                  checked
                    ? [...current, permission.id]
                    : current.filter((id) => id !== permission.id),
                )
              }
            />
            <Label htmlFor={`permissions-${permission.id}`}>{permission.label}</Label>
          </HStack>
        ))}
      </VStack>
    </VStack>
  )
}

indeterminate is a display state. Clicking the checkbox still fires onCheckedChange with the opposite of checked, so decide what a click on a mixed parent means and set checked from that.

API reference

Pass render to replace a part's element or compose it with another component.

Checkbox wraps the Base UI Checkbox.Root and renders its Indicator inside. See the Base UI reference for the full list of props.

Checkbox

PropTypeDefault
checkedboolean—
defaultCheckedbooleanfalse
onCheckedChange(checked: boolean, details) => void—
indeterminatebooleanfalse
disabledbooleanfalse
readOnlybooleanfalse
requiredbooleanfalse
namestring—
valuestring—
idstring—
inputRefRef<HTMLInputElement>—
xstyleStyleXStyles—
styleCSSProperties—
classNamestring—

The root is a <span role="checkbox">. Base UI renders a hidden <input> beside it that carries id, name and value. A label's htmlFor and a form submission reach the control through that input.

checkboxStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
rootThe box: size, border width, corner, hit area
choiceThe hairline edge and fill at a choice's size
checkedThe flat primary fill and inverted tick colour
disabledThe dimmed, non-interactive state
invalidThe destructive border and ring

root carries no colour and no focus ring. Everything else comes from states in theme/states.ts. Merge choice, then states.focusRing. Merge invalid before checked, so a ticked invalid box keeps the primary fill and shows only the destructive ring.

Data attributes

AttributeOnEffect
data-slot="checkbox"the rootTargets the box from a parent
data-slot="checkbox-indicator"the indicatorTargets the animated indicator
data-checked, data-uncheckedroot, indicatorBase UI writes them
data-parentrootControls the group selection
data-indeterminateroot, indicatorBase UI writes it
data-disabled, data-readonly, data-requiredroot, indicatorBase UI writes them
data-starting-style, data-ending-stylethe indicatorBase UI writes them while the tick enters or leaves