Teseria
All elements

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

Terminal
# No packages beyond React.

2. Files

Checkbox.tsx72 lines
"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.css130 lines
/* 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;
  }
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Checkbox } from "./Checkbox";
import "./Checkbox.css";

export default function Demo() {
  return <Checkbox />;
}