Lunia DS

Base components

Checkbox

A checkbox with an optional label and supporting text, where the box fills with the brand color and the check mark draws itself in, and the mixed state shows a short line.

Loading preview

A list of notification settings with a parent "All notifications" box that becomes mixed when only some children are checked, both sizes, a disabled row and an invalid "I accept the terms" box with the line "Check this box to continue." Replay unchecks every box and checks them again one by one.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add checkbox

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Checkbox } from './checkbox';

const STEP_MS = 400;
const CHILDREN = ['Comments', 'Mentions', 'Direct messages'];
// Comments, Mentions, Direct messages, Product updates, terms
const INITIAL = [true, false, true, true, false];

export default function CheckboxDemo({ replay = 0 }: { replay?: number }) {
  const [on, setOn] = React.useState(INITIAL);
  const set = (i: number) => (value: boolean) => setOn((list) => list.map((v, j) => (j === i ? value : v)));
  const children = on.slice(0, 3);
  const all = children.every(Boolean);
  const some = children.some(Boolean);

  React.useEffect(() => {
    if (replay === 0) return;
    setOn(INITIAL.map(() => false));
    // Every box is checked again, one after the other.
    const timers = INITIAL.map((_, i) =>
      window.setTimeout(() => setOn((list) => list.map((v, j) => (j === i ? true : v))), (i + 1) * STEP_MS),
    );
    return () => {
      for (const t of timers) window.clearTimeout(t);
    };
  }, [replay]);

  return (
    <div className="grid gap-x-12 gap-y-8 sm:grid-cols-2">
      <div className="flex flex-col gap-3">
        <Checkbox
          label="All notifications"
          checked={all}
          indeterminate={some && !all}
          onCheckedChange={() => setOn((list) => list.map((v, j) => (j < 3 ? !all : v)))}
        />
        <div className="flex flex-col gap-3 ps-6">
          {CHILDREN.map((label, i) => (
            <Checkbox key={label} label={label} checked={on[i]} onCheckedChange={set(i)} />
          ))}
        </div>
      </div>
      <div className="flex flex-col gap-4">
        <Checkbox
          size="md"
          label="Product updates"
          supportingText="News about features and changes."
          checked={on[3]}
          onCheckedChange={set(3)}
        />
        <Checkbox size="md" label="Weekly summary" checked disabled />
        <Checkbox
          size="md"
          label="I accept the terms"
          supportingText={on[4] ? 'You can read the terms at any time.' : 'Check this box to continue.'}
          invalid={!on[4]}
          required
          checked={on[4]}
          onCheckedChange={set(4)}
        />
      </div>
    </div>
  );
}

API

Parts: root (label element, the whole row is the tap area), control (box), check mark (stroked path), indeterminate line, text: label and supporting text.

PropTypeDefaultDescription
checkedbooleanundefinedControlled checked state.
defaultCheckedbooleanfalseChecked state when uncontrolled.
indeterminatebooleanfalseShows the mixed state, for example a parent box when only some children are checked.
size"sm" | "md""sm"Box of 16 or 20px, with Text sm or Text md.
labelstringundefinedLabel text. Without it, pass aria-label.
supportingTextstringundefinedSecond line under the label.
invalidbooleanfalseError border on an unchecked box and red supporting text, for a required box left unchecked (Figma Destructive).
disabledbooleanfalseFades the control to 50%; the text stays at full opacity.
requiredbooleanfalseNative required for form validation.
namestringundefinedForm field name.
valuestring"on"Value sent with the form when checked.
EventDescription
checkedChangeThe checked state changed. Payload: the new checked value.
SlotDescription
defaultLabel content, replaces the label prop.
supportingTextSupporting content, replaces the supportingText prop.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
CHK1hover an unchecked box (or its label)controlbackground-color, border-colorbg-primary, border-control → bg-primary_hover, border-brandduration-sm (200)ease-outSwaps at once
CHK2checked -> truecontrolbackground-color, border-colorbg-primary, border-control → bg-brand-solid, bg-brand-solidduration-sm (200)ease-outSwaps at once
CHK3checked -> truecheck markstroke-dashoffset (draw from the short arm to the long arm)path length → 0duration-md (260)ease-outdelay 60msShows at once
CHK4checked -> falsecheck markopacity1 → 0duration-2xs (120)ease-exitHides at once
CHK5checked -> falsecontrolbackground-color, border-colorbg-brand-solid, bg-brand-solid → bg-primary, border-controlduration-xs (160)ease-outSwaps at once
CHK6indeterminate -> trueindeterminate lineopacity, scale x0, scale-swap → 1, 1duration-sm (200)ease-outShows at once
CHK7indeterminate -> checked (or back)indeterminate line and check markopacity (crossfade); the check mark then draws as CHK3old mark 1 → new mark 1duration-sm (200)ease-outSwaps at once
CHK8press (pointer down or Space held)controlscale1 → scale-pressduration-sm (200)ease-outStays still
CHK9keyboard focuscontrolbox-shadownone → focus ring (focus-ring, or focus-ring-error when invalid)duration-2xs (120)ease-outShows at once
CHK10invalid -> truecontrol border, supporting textborder-color, colorborder-control, text-tertiary → border-error, text-error-primaryduration-sm (200)ease-outSwaps at once

Accessibility

  • Built on a checkbox primitive: role checkbox with aria-checked true, false or mixed, Space toggles, and a hidden native input takes part in forms.
  • The label and supporting text are linked with aria-labelledby and aria-describedby; clicking the text toggles the box.
  • Without label text, the box is 16 or 20px: give it a 24 x 24px tap area or 4px of space around it (WCAG 2.5.8). With text, the whole row is the tap area.
  • invalid sets aria-invalid; the supporting text explains what to do, not only the red color.
  • Visible focus ring on keyboard focus only (focus-visible).
  • Reduced motion: the box fills and the check mark shows at once, and nothing scales.