Checkbox
A real <input type="checkbox"> wrapped in a label. The box's fill pops in from its centre, the mark draws itself a beat later, and the label dims once it is on.
Code
1. Install
# No packages beyond React.
2. Files
"use client";
// Checkbox: a real <input type="checkbox"> wrapped in a label. The box's fill pops in from its centre, the mark
// draws itself a beat later, and the label dims once it is on. "indeterminate" swaps the tick for a dash.
// All motion is CSS transitions keyed off a data-mark attribute, so there is no animation loop in JS.
import { useId, useState, type CSSProperties, type ReactNode } from "react";
export type CheckboxCue = "enable" | "disable";
export type CheckboxProps = {
/** Checked state (controlled). */
checked?: boolean;
/** Initial state when uncontrolled. */
defaultChecked?: boolean;
/** Show a dash instead of a tick, for a "some selected" parent. */
indeterminate?: boolean;
/** Called with the new checked state. */
onChange?: (checked: boolean) => void;
/** Text next to the box. Pass null for a bare box. */
label?: ReactNode;
/** Muted line under the label. */
hint?: ReactNode;
disabled?: boolean;
/** Box size in px, 14 to 32. */
size?: number;
/** 0 to 100: how far the fill overshoots as it pops in. */
bounce?: number;
/** Form field name. */
name?: string;
/** Sound hook: "enable" when checking, "disable" when unchecking. */
onSound?: (cue: CheckboxCue) => void;
className?: string;
style?: CSSProperties;
};
const between = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
export function Checkbox({ checked, defaultChecked = false, indeterminate = false, onChange, label = "Email me receipts", hint, disabled, size = 20, bounce = 55, name, onSound, className, style }: CheckboxProps) {
const uid = useId();
const [held, setHeld] = useState(defaultChecked);
const isOn = checked ?? held;
const mark = indeterminate ? "dash" : isOn ? "tick" : "none";
const side = between(size, 14, 32);
// overshoot of the pop curve scales with the bounce knob
const overshoot = 1 + (between(bounce, 0, 100) / 100) * 0.68;
const flip = () => {
if (disabled) return;
onSound?.(isOn ? "disable" : "enable");
if (checked === undefined) setHeld(!isOn);
onChange?.(!isOn);
};
const vars = { "--chk-overshoot": `cubic-bezier(0.28, ${overshoot.toFixed(3)}, 0.36, 1)`, ...style } as CSSProperties;
return (
<label htmlFor={uid} className={className ? `chk-root ${className}` : "chk-root"} style={vars} data-mark={mark} data-disabled={disabled || undefined}>
<input id={uid} className="chk-in" type="checkbox" name={name} checked={isOn} disabled={disabled} aria-checked={indeterminate ? "mixed" : isOn} onChange={flip} />
<span className="chk-box" style={{ width: side, height: side, borderRadius: side * 0.3 }}>
<span className="chk-fill" />
<svg className="chk-glyph" viewBox="0 0 24 24" fill="none" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path className="chk-tick" d="M5.5 12.6l4.3 4.3L18.5 7.4" pathLength={1} />
<path className="chk-dash" d="M6.5 12h11" pathLength={1} />
</svg>
</span>
{label && (
<span className="chk-copy">
<span className="chk-name">{label}</span>
{hint && <span className="chk-sub">{hint}</span>}
</span>
)}
</label>
);
}
/* Checkbox. Prefix chk-. Motion is driven by [data-mark="none|tick|dash"] on the root. */
.chk-root,
.chk-root * {
box-sizing: border-box;
}
.chk-root {
--chk-out: cubic-bezier(.22,1,.36,1);
--chk-overshoot: cubic-bezier(.28,1.37,.36,1);
--chk-rim: .3;
position: relative;
display: inline-flex;
align-items: flex-start;
gap: 10px;
color: var(--ts-fg);
font-family: var(--ts-font, system-ui, sans-serif);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.chk-root[data-disabled] {
opacity: .4;
cursor: default;
}
.chk-in {
position: absolute;
margin: 0;
clip-path: inset(50%);
width: 1px;
height: 1px;
pointer-events: none;
}
/* the box: a ring drawn by ::before, so hover and focus only touch the border */
.chk-box {
position: relative;
flex: none;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: scale 220ms var(--chk-out);
}
.chk-box::before {
content: "";
position: absolute;
inset: 0;
z-index: 2;
border: 1.5px solid rgba(var(--ts-fg-rgb), var(--chk-rim));
border-radius: inherit;
transition: border-color 160ms var(--chk-out);
}
.chk-root:not([data-disabled]):hover {
--chk-rim: .55;
}
.chk-root:not([data-disabled]):active .chk-box {
scale: .9;
}
.chk-in:focus-visible + .chk-box {
--chk-rim: .55;
outline: 2px solid var(--ts-accent);
outline-offset: 2px;
}
.chk-fill {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--ts-fg);
scale: 0;
transition: scale 160ms cubic-bezier(.4,0,1,1);
}
.chk-root:not([data-mark="none"]) .chk-fill {
scale: 1;
transition: scale 420ms var(--chk-overshoot);
}
/* the mark: two strokes, one drawn at a time */
.chk-glyph {
position: relative;
z-index: 1;
width: 70%;
height: 70%;
overflow: visible;
stroke: var(--ts-raised);
}
.chk-tick,
.chk-dash {
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0;
transition: stroke-dashoffset 120ms ease-in, opacity 0s 120ms;
}
.chk-root[data-mark="tick"] .chk-tick,
.chk-root[data-mark="dash"] .chk-dash {
stroke-dashoffset: 0;
opacity: 1;
transition: stroke-dashoffset 280ms var(--chk-out) 110ms, opacity 0s;
}
.chk-root[data-mark="dash"] .chk-dash {
transition-delay: 130ms, 0s;
}
.chk-copy {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 1px;
font-size: 14px;
line-height: 1.25;
letter-spacing: -.01em;
}
.chk-name {
font-weight: 500;
transition: opacity 220ms var(--chk-out);
}
.chk-root:not([data-mark="none"]) .chk-name {
opacity: .72;
}
.chk-sub {
font-size: 12px;
color: color-mix(in srgb, var(--ts-raised), var(--ts-fg) 52%);
}
@media (prefers-reduced-motion: reduce) {
.chk-root * {
transition-duration: 10ms !important;
transition-delay: 0s !important;
}
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Checkbox } from "./Checkbox";
import "./Checkbox.css";
export default function Demo() {
return <Checkbox />;
}