Components

Getting started

Components144

More

A real checkbox whose fill grows from the centre and whose tick draws itself, with a mixed state that bends into the tick.

Used forSettings and consentTable row selectionTask and to-do listsSign-up terms

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/checkbox.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Settings and consent

    Labelled options with a second line of description.

  • Table row selection

    A header box that shows "some selected" as a dash.

  • Task and to-do lists

    Ticks that draw as you finish things.

  • Sign-up terms

    A required box inside a native form.

What it handles for you

Rebuilding a checkbox from divs means rebuilding forms, labels, Space and screen-reader state. This keeps the native input and only draws over it.

Instead of wiring upcustom checkboxindeterminate wiringtick animation
  1. A tick that draws

    pathLength stroke, 300ms after the fill lands; it retracts on uncheck.

  2. Mixed state

    checked="mixed" sets indeterminate on the input and grows a dash.

  3. Native underneath

    Forms, labels, Space and aria-checked behave as always.

  4. Press feedback

    The box gives under the finger and settles back.

Overview

Custom checkboxes often drop the native input and lose forms, labels and Space. This one keeps a real input underneath; on top, the box fills from its centre with a slight overshoot and the tick draws along its stroke, retracting the way it came. A mixed dash grows from the middle for "some selected".

States

unchecked
A hairline box; hover darkens the line.
checked
Primary fill grows from 0.4 with a slight overshoot (320ms) and the tick draws (300ms, 90ms later).
mixed
A dash grows from the centre (260ms).
focus
The ring and a 3px halo, from the keyboard only.

Usage

usage.tsx
import { Checkbox } from "@/components/ui/checkbox";<Checkbox label="Email me about new templates" description="About once a month." defaultChecked /><Checkbox checked={some ? "mixed" : all} onCheckedChange={selectAll} aria-label="Select all" />

Props

PropTypeDescription
checkedboolean | "mixed"Controlled state.
defaultCheckedbooleandefault falseInitial state when uncontrolled.
onCheckedChange(checked: boolean) => voidCalled with the new state.
label / descriptionReactNodeText beside the box.
size"sm" | "md"default "md"14px or 16px box.
...input propsInputHTMLAttributesname, value, required, disabled, ref.

Accessibility and motion

  • Reduced motion: the fill and tick change at once.
  • The whole label is the hit area.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/checkbox.tsx
"use client";import * as React from "react";import { EASE_OUT, EASE_THROW, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * CHECKBOX: a tick that draws itself * *   check    the box fills from its centre (a slight overshoot) *            and the tick draws itself along its stroke *   uncheck  the tick retracts the way it came, the fill shrinks *   mixed    a dash grows from the middle; mixed → checked bends *            the dash into the tick *   press    the box gives under the finger * * A real <input type="checkbox"> underneath, so forms, labels, * Space and screen readers all work as they always do. * ───────────────────────────────────────────────────────── */export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size" | "checked" | "defaultChecked"> {  checked?: boolean | "mixed";  defaultChecked?: boolean;  onCheckedChange?: (checked: boolean) => void;  label?: React.ReactNode;  description?: React.ReactNode;  /** 14px or 16px box. */  size?: "sm" | "md";  ref?: React.Ref<HTMLInputElement>;}export function Checkbox({  checked,  defaultChecked = false,  onCheckedChange,  label,  description,  size = "md",  disabled,  className = "",  onChange,  ref,  id,  ...props}: CheckboxProps) {  const reduced = useInteractionReducedMotion();  const auto = React.useId();  const inputId = id ?? auto;  const inner = React.useRef<HTMLInputElement>(null);  const [own, setOwn] = React.useState(defaultChecked);  const state = checked !== undefined ? checked : own;  const on = state === true;  const mixed = state === "mixed";  React.useEffect(() => {    if (inner.current) inner.current.indeterminate = mixed;  }, [mixed]);  const box = size === "sm" ? "size-3.5 rounded-[4px]" : "size-4 rounded-[5px]";  const t = (ms: number, delay = 0, ease = EASE_OUT) => (reduced ? "none" : `${ms}ms ${ease} ${delay}ms`);  return (    <label      htmlFor={inputId}      className={`group/cb inline-flex select-none items-start gap-2.5 ${disabled ? "cursor-default opacity-50" : "cursor-pointer"} ${className}`}    >      <span className={`relative mt-[2px] inline-flex shrink-0 ${box}`}>        <input          ref={(el) => {            inner.current = el;            if (typeof ref === "function") ref(el);            else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el;          }}          id={inputId}          type="checkbox"          checked={on}          disabled={disabled}          aria-checked={mixed ? "mixed" : on}          onChange={(e) => {            if (checked === undefined) setOwn(e.target.checked);            onChange?.(e);            onCheckedChange?.(e.target.checked);          }}          className="peer absolute inset-0 m-0 size-full cursor-[inherit] appearance-none rounded-[inherit] outline-none"          {...props}        />        {/* The box: a hairline that the fill grows over. */}        <span          aria-hidden="true"          className={`pointer-events-none absolute inset-0 rounded-[inherit] bg-background shadow-[inset_0_0_0_1px_var(--border)] transition-[box-shadow,scale] duration-200 group-hover/cb:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_30%,var(--border))] group-active/cb:scale-[0.88] peer-focus-visible:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_20%,transparent)] motion-reduce:group-active/cb:scale-100`}        >          <span            className="absolute inset-0 rounded-[inherit] bg-primary"            style={{ transform: on || mixed ? "scale(1)" : "scale(0.4)", opacity: on || mixed ? 1 : 0, transition: reduced ? "none" : `transform ${t(320, 0, EASE_THROW)}, opacity ${t(160)}` }}          />          <svg viewBox="0 0 16 16" fill="none" className="absolute inset-0 size-full text-primary-foreground">            {/* The tick draws on the way in and retracts on the way out. */}            <path              d="M4 8.4 6.8 11 12 5.2"              stroke="currentColor"              strokeWidth="2"              strokeLinecap="round"              strokeLinejoin="round"              pathLength={1}              strokeDasharray={1}              style={{ strokeDashoffset: on ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset ${on ? t(300, 90) : t(160)}` }}            />            <path              d="M4.5 8h7"              stroke="currentColor"              strokeWidth="2"              strokeLinecap="round"              style={{ transform: mixed ? "scaleX(1)" : "scaleX(0)", transformOrigin: "8px 8px", transition: reduced ? "none" : `transform ${t(260, mixed ? 60 : 0)}` }}            />          </svg>        </span>      </span>      {(label || description) && (        <span className="flex min-w-0 flex-col gap-0.5">          {label && <span className="text-[13.5px] font-medium leading-5 text-foreground">{label}</span>}          {description && <span className="text-[12.5px] leading-snug text-muted-foreground">{description}</span>}        </span>      )}    </label>  );}export default Checkbox;

Registry item: /r/checkbox.json