Checkbox

Binary choice inside a form, committed when the form is submitted.

Forms Checkbox.jsx Checkbox.d.ts Checkbox.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Checkbox is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Checkbox } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Binary choice inside a form, committed when the form is submitted.

Use it when

  • Opting in or out: terms, notifications, add-ons.
  • Selecting several items from a list.
  • indeterminate: a "select all" row over a partial selection.

Don't use it when

  • The change applies immediately. Use Switch.
  • The options are mutually exclusive. Use Radio.

Example

<Checkbox label="Email me about product updates" hint="About one message a month" />
<Checkbox label="Select all" indeterminate={some && !all} onChange={toggleAll} />

Variants

labelPosition="start" puts the label on the left and the box at the right edge of the row. Use it in a column of options under a heading or field label, where a leading box would indent every label past the text above it. It's the same settings-row pattern as Switch.

<Checkbox label="Include archived invoices" labelPosition="start" />

Accessibility

The real input stays in the DOM and receives focus, so keyboard and screen-reader behaviour is native. The indeterminate flag is set on the element, not faked with an attribute.

Content

Label states what checking it does, phrased positively. "Email me about product updates", never "Do not email me".

Props

The typed contract, from Checkbox.d.ts.

import * as React from "react";

/** Binary choice within a form. Supports an indeterminate state for parent rows. */
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> {
  label?: React.ReactNode;
  /** Secondary line below the label. */
  hint?: string;
  checked?: boolean;
  defaultChecked?: boolean;
  /** Visual dash state for a parent controlling a partially-selected group. */
  indeterminate?: boolean;
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
  disabled?: boolean;
  /** "start" puts the label on the left and pushes the control to the right edge, so labels in a column line up with the text above them instead of indenting past the control. @default "end" */
  labelPosition?: "start" | "end";
}

export declare function Checkbox(props: CheckboxProps): React.JSX.Element;

Tokens it reads

Every custom property Checkbox.jsx resolves, read from the source: 1 component, 13 semantic, 1 primitive. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-input-bgcomponentvar(--dt-surface-base)
--dt-border-strongsemanticvar(--dt-color-neutral-400)
--dt-border-width-strongsemanticvar(--dt-dim-hair-2)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-surface-actionsemanticvar(--dt-color-neutral-900)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-radius-raw-4primitive4px

Source

React only, no dependencies, every value a token.

import React from "react";

export function Checkbox({ label, hint, checked, defaultChecked = false, indeterminate = false, onChange, disabled = false, id, labelPosition = "end", style, ...rest }) {
  const [internal, setInternal] = React.useState(defaultChecked);
  const ref = React.useRef(null);
  const auto = React.useId();
  const boxId = id || auto;
  const isOn = checked !== undefined ? checked : internal;

  React.useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]);

  const handle = (e) => {
    if (checked === undefined) setInternal(e.target.checked);
    onChange && onChange(e);
  };

  return (
    <div style={{ display: "flex", gap: "var(--dt-space-inline-xs)", opacity: disabled ? 0.6 : 1, ...style }}>
      <input
        ref={ref} type="checkbox" id={boxId} checked={isOn} onChange={handle} disabled={disabled}
        style={{ position: "absolute", opacity: 0, width: 0, height: 0 }}
        {...rest}
      />
      <label
        htmlFor={boxId}
        style={{
          display: "flex", gap: "var(--dt-space-inline-xs)", cursor: disabled ? "not-allowed" : "pointer", alignItems: hint ? "flex-start" : "center",
          justifyContent: labelPosition === "start" ? "space-between" : undefined,
          width: labelPosition === "start" ? "100%" : undefined,
          flexDirection: labelPosition === "start" ? "row-reverse" : "row",
        }}
      >
        <span
          aria-hidden="true"
          style={{
            flex: "none", width: 18, height: 18, marginTop: hint ? 2 : 0, boxSizing: "border-box",
            borderRadius: "var(--dt-radius-raw-4)",
            border: `var(--dt-border-width-strong) solid ${isOn || indeterminate ? "var(--dt-surface-action)" : "var(--dt-border-strong)"}`,
            background: isOn || indeterminate ? "var(--dt-surface-action)" : "var(--dt-input-bg)",
            display: "flex", alignItems: "center", justifyContent: "center",
            transition: "background var(--dt-motion-micro), border-color var(--dt-motion-micro)",
          }}
        >
          {indeterminate ? (
            <svg width="10" height="10" viewBox="0 0 24 24" stroke="var(--dt-text-on-action)" strokeWidth="4" strokeLinecap="round"><path d="M5 12h14" /></svg>
          ) : isOn ? (
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--dt-text-on-action)" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
          ) : null}
        </span>
        <span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
          <span style={{ fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)", lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-primary)" }}>{label}</span>
          {hint && <span style={{ fontSize: "var(--dt-text-body-xs-size)", lineHeight: "var(--dt-text-body-xs-line)", color: "var(--dt-text-secondary)" }}>{hint}</span>}
        </span>
      </label>
    </div>
  );
}

In context

Checkbox also appears on the forms reference cards, alongside the rest of its family.