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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| checked / onCheckedChangeboolean | "indeterminate" / (checked) => void | boolean | "indeterminate" / (checked) => void | Controlled. Pressing an indeterminate box checks it. |
| defaultCheckedboolean | "indeterminate" | boolean | "indeterminate"= false | Uncontrolled start. |
| labelReactNode | ReactNode | Beside the box; clicking it toggles. Without one, give the box an aria-label. |
| descriptionReactNode | ReactNode | A quieter line under the label, linked with aria-describedby. |
| name / value / requiredstring / string / boolean | string / string / boolean | Passed to the input, so it takes part in a form. |
| disabledboolean | boolean | Dims 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.
"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>
);
});