← All components

Checkbox

An ink-filled box whose check draws itself in, with a bar for a parent that is partly on.

The idea

A real checkbox input, styled rather than replaced, so Space, a wrapping label, form submission, reset and required all behave as they would natively — and the browser's own indeterminate state is what a screen reader announces as mixed. The input is the visible box, so the focus ring lands on what you see. The check draws in once the fill has arrived; unchecking does not undraw it, because a correction should not perform.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Checkbox — usage12 lines
import { Checkbox } from "mihirmodi-ui";

// A parent is "indeterminate" while only some of its children are on.
// Pressing it then checks everything.
const all = picked.length === topics.length ? true : picked.length ? "indeterminate" : false;

<Checkbox label="Email me about activity" checked={all} onCheckedChange={(c) => setPicked(c ? topics : [])} />
<Checkbox label="Mentions" checked={picked.includes("mentions")} onCheckedChange={…} />

// A real <input type="checkbox"> underneath, so name, required and
// form reset all work as they would on a native one.
<Checkbox name="terms" required label="I agree to the terms" />

Props

PropNotes
checked / onCheckedChangeboolean | "indeterminate" / (checked) => voidControlled. Pressing an indeterminate box checks it.
defaultCheckedboolean | "indeterminate"Uncontrolled start.
labelReactNodeBeside the box; clicking it toggles. Without one, give the box an aria-label.
descriptionReactNodeA quieter line under the label, linked with aria-describedby.
name / value / requiredstring / string / booleanPassed to the input, so it takes part in a form.
disabledbooleanDims the box and its label together.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/forms/Checkbox.tsx131 lines
"use client";

import {
  forwardRef,
  useEffect,
  useId,
  useImperativeHandle,
  useRef,
  useState,
  type CSSProperties,
  type InputHTMLAttributes,
  type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";

export type CheckedState = boolean | "indeterminate";

export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "size"> & {
  /** Controlled. `"indeterminate"` draws a bar — a parent whose children are partly chosen. */
  checked?: CheckedState;
  defaultChecked?: CheckedState;
  /** Called with the new state. Pressing an indeterminate box checks it. */
  onCheckedChange?: (checked: boolean) => void;
  /** The text beside the box. Clicking it toggles the box. */
  label?: ReactNode;
  /** A quieter line under the label, linked as the box's description. */
  description?: ReactNode;
};

/* The check draws in once the fill has arrived, on the curve that never
   overshoots, so the stroke reads as a pen finishing a mark. On the way out
   nothing is undrawn — the mark fades with the fill and the stroke resets
   while it is invisible. Unchecking is a correction, and a correction should
   not perform. */
const DRAW = "stroke-dashoffset 260ms cubic-bezier(0.16, 1, 0.3, 1) 50ms, opacity 0s";
const UNDRAW = "opacity 120ms ease-out, stroke-dashoffset 0s linear 120ms";

function mark(on: boolean, reduce: boolean | null): CSSProperties {
  if (reduce) return { strokeDashoffset: 0, opacity: on ? 1 : 0, transition: "opacity 150ms ease-out" };
  return { strokeDashoffset: on ? 0 : 1, opacity: on ? 1 : 0, transition: on ? DRAW : UNDRAW };
}

/**
 * A checkbox: an ink-filled box with a check that draws itself in.
 *
 * Underneath it is a real `<input type="checkbox">`, styled rather than
 * replaced. That buys everything a form expects without re-implementing it —
 * Space toggles it, a wrapping label toggles it, it submits under its `name`,
 * a form reset restores it, `required` validates — and the browser's own
 * `indeterminate` property is what a screen reader announces as "mixed". The
 * input itself is the visible box (`appearance: none`), so the host's focus
 * ring lands on the thing you see, not on a hidden 1px proxy.
 *
 * The empty box is a full pixel at the hover-border step rather than a
 * hairline: a 16px square whose only edge is a hairline is a control nobody
 * can find. Checked, it fills with ink — fill means chosen everywhere here —
 * and the press is a 10% shrink, which on a 16px box is the same travel a
 * button's 3% is on a 36px one.
 */
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
  { checked, defaultChecked = false, onCheckedChange, label, description, disabled, id, className = "", ...rest },
  ref
) {
  const reduce = useReducedMotion();
  const auto = useId();
  const inputId = id ?? auto;
  const descId = `${inputId}-desc`;
  const input = useRef<HTMLInputElement>(null);
  useImperativeHandle(ref, () => input.current as HTMLInputElement);

  const [inner, setInner] = useState<CheckedState>(defaultChecked);
  const state = checked !== undefined ? checked : inner;
  const on = state !== false;

  useEffect(() => {
    if (input.current) input.current.indeterminate = state === "indeterminate";
  }, [state]);

  const box = (
    <span className="relative flex h-4 w-4 shrink-0 transition-transform duration-150 ease-geist group-active:scale-90 motion-reduce:transition-none motion-reduce:group-active:scale-100">
      <input
        ref={input}
        id={inputId}
        type="checkbox"
        checked={state === true}
        disabled={disabled}
        aria-describedby={description ? descId : undefined}
        onChange={(e) => {
          if (checked === undefined) setInner(e.target.checked);
          onCheckedChange?.(e.target.checked);
        }}
        className={`peer m-0 h-4 w-4 shrink-0 cursor-[inherit] appearance-none rounded-[6px] border transition-[background-color,border-color] duration-150 ease-out motion-reduce:transition-none ${
          on ? "border-ink bg-ink" : "border-gray-alpha-600 bg-background-100 enabled:group-hover:bg-gray-100"
        }`}
        {...rest}
      />
      <svg
        viewBox="0 0 16 16"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden
        className="pointer-events-none absolute inset-0 h-4 w-4 text-background-100"
      >
        <path d="M4.2 8.3l2.6 2.6 5-5.6" pathLength={1} strokeDasharray={1} style={mark(state === true, reduce)} />
        <path d="M4.75 8h6.5" pathLength={1} strokeDasharray={1} style={mark(state === "indeterminate", reduce)} />
      </svg>
    </span>
  );

  if (!label) return <span className={`group inline-flex ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"} ${className}`}>{box}</span>;

  return (
    <label
      htmlFor={inputId}
      className={`group inline-flex select-none items-start gap-2.5 ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"} ${className}`}
    >
      <span className="mt-0.5 flex">{box}</span>
      <span className="flex min-w-0 flex-col gap-0.5">
        <span className="text-[14px] leading-5 text-gray-1000">{label}</span>
        {description && (
          <span id={descId} className="text-[12px] leading-[18px] text-muted">
            {description}
          </span>
        )}
      </span>
    </label>
  );
});