registry:ui · Data Entry
checkbox — the native selector, redrawn
Pure-CSS checkbox: appearance-none + 16px square + 1px border, :checked fills primary with a white checkmark (clip-path polygon, zero icon deps), :indeterminate morphs to a horizontal bar, error wiring (aria-invalid + describedby + dashed border + monochrome ! message). Label on inline-start.
$npx jixoai-ui add checkbox
https://ui.jixoai.com/r/checkbox.json
overview
Overview
A checkbox that never stopped being a checkbox: the native input keeps every behavior, the paint is redrawn around it.
The component strips appearance off the native input and draws the glyph with
one ::before — a clip-path polygon on a 45°-rotated box. No icon font, no SVG,
no dependencies. Underneath, the real <input type="checkbox"> keeps form
participation, keyboard toggling, and the :checked/:indeterminate state
machine: :checked fills primary and grows the white check out of a collapsed
polygon; :indeterminate rotates the same box back to 0° and morphs it into a
dash — six vertices in every state, so CSS interpolates the morph.
The default render is a field wrapper holding a check lane — the input and a label[for] on one row (labelSide flips the side) — plus an error line
when error is set. With no label and no error the wrapper collapses to an
inline fit-content host. Hover leans the unchecked border toward primary; error dashes
the border and wires aria-invalid + aria-describedby.
Uncontrolled by design: read submitted values with FormData. Pass bind:checked for controlled two-way binding. indeterminate is an IDL
property — the attribute never reflects, so the prop lands on the element via an effect.
The eight universal axes resolve on the field wrapper — on this family density and theme repaint the control, the other six stamp
and supply without touching the paint (per-axis below). The bare prop renders
the single input for prose contexts — the unstamped embedded lane, its own section below.
The shared grammar lives on the universal props page.
usage
Usage
Keep the input native so labels, keyboard toggling, and FormData participation remain platform behavior. The playground's drawer shows the exact registry copy this page runs.
examples
The selector, redrawn in pure CSS
The state matrix: binary, tri-state, and validation states are all the native states, redrawn — one pseudo-element, six vertices in every state.
checkbox · states
consent is required
<script lang="ts">
import Checkbox from '@ui/checkbox';
import CardGrid from '@ui/card-grid';
</script>
<CardGrid min="200px">
<div class="demo-cell" data-no-subgrid>
<Checkbox label="unchecked" name="demo_cb" />
</div>
<div class="demo-cell" data-no-subgrid>
<Checkbox label="checked" name="demo_cb" checked />
</div>
<div class="demo-cell" data-no-subgrid>
<Checkbox label="indeterminate" name="demo_cb" indeterminate />
</div>
<div class="demo-cell" data-no-subgrid>
<Checkbox label="label left" name="demo_cb" labelSide="left" />
</div>
<div class="demo-cell" data-no-subgrid>
<Checkbox label="disabled" name="demo_cb" disabled />
</div>
<div class="demo-cell" data-no-subgrid>
<Checkbox label="error" name="demo_cb" error="consent is required" />
</div>
</CardGrid>examples
In a submitted form
Uncontrolled field, native constraint validation (the required bubble belongs to the platform), FormData read once at submit — the checked box contributes its name/value pair, an unchecked one contributes nothing.
checkbox · in a form
sign up — the FormData payload prints here<script lang="ts">
import Checkbox from '@ui/checkbox.svelte';
import PressButton from '@ui/press-button.svelte';
import { rt } from '@lib/surface/routes.stylex';
// the page-local join (the separator serialize law) — the same idiom
// the docs stage runs; atoms over utility classes
const cx = (
...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
): string =>
styles
.filter(Boolean)
.map((style) =>
typeof style === 'string'
? style
: Object.entries(style ?? {}).flatMap(([key, value]) =>
key !== '$$css' && typeof value === 'string' ? [value] : [],
).join(' '),
)
.join(' ');
// uncontrolled fields, FormData read once at submit — the checked box
// contributes its name/value pair, an unchecked one contributes nothing
let result = $state<string[] | null>(null);
function onSubmit(event: SubmitEvent) {
event.preventDefault();
const data = new FormData(event.currentTarget as HTMLFormElement);
const outputs: string[] = [];
for (const [key, value] of data) {
if (typeof value === 'string' && value !== '') outputs.push(key + ': ' + value);
}
result = outputs;
}
</script>
<form class={cx(rt.col16)} aria-label="consent" onsubmit={onSubmit}>
<Checkbox label="I agree to the terminal printing my answers" name="consent" value="yes" required />
<Checkbox label="join the newsletter" name="news" value="yes" />
<div class={cx(rt.wrapRow12, rt.pt4)}>
<PressButton type="submit" variant="fill">sign up</PressButton>
<span class={cx(rt.inkMuted, rt.text125)}>
required fields use native validation — try submitting empty
</span>
</div>
</form>props
Props
Props extend the native HTML input attributes — minus size and color, whose names the eight-axis surface owns (the §1 native collision rule); the entries below are checkbox-specific additions, and everything else rides the rest object through to the input.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| label | string | — | Same-row label rendered with label[for]. |
| error | string | — | Adds invalid state and an associated message. |
| labelSide | 'left' | 'right' | 'right' | Places the label before or after the control. |
| indeterminate | boolean | false | Sets the native indeterminate IDL state. |
| bare | boolean | false | Presentation-only single input — no wrapper/lane/label chrome (the markdown task-item unlock: a direct-child input keeps the container-level DOM-shape laws working). |
checked bind | boolean | — | Bindable controlled checked state. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on checkbox
Checkbox resolves all eight axes through its family Defaults (CheckboxDefaults) and stamps the resolved carriers on the field wrapper — bare renders no carrier element at all (the next section). What each axis drives on THIS family: density repaints the control (the named rung — the number lane is inert here), theme flips every painted voice (they all live on the raw token layer), and six axes stamp-and-supply without touching the paint. The lane grammar (named · auto · number · query()) is the universal props page's.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | Stamps --jx-size-effective and the root font-size on the field wrapper — and nothing in this family reads either: the label declares its own font-size var(--jx-text) (a density channel, beats inheritance), and the box reads the rem-anchored var(--jx-icon), which tracks the document root, not this stamp. A documented non-consumer (grep receipt: zero --jx-size-effective readers in the checkbox chain). The §1 collision still applies: the axis owns the name — the element never receives a native size attribute. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | Stamps --jx-shape-effective and --jx-radius-factor-effective, supplied downward. The box reads var(--corner-shape, bevel) — an undeclared customization seam whose bevel fallback wins today; the shape axis never bridges it — and its border-radius is 0: the glyph is deliberately square. Supply-only on this family. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | Stamps --jx-radius-effective — the concentric anchor a nested auto part computes against. Nothing consumes it here: the control's corner is border-radius: 0 by design and the chain carries no radius composition. Supply-only. |
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | The axis that repaints this control — through its NAMED lane. A named rung stamps the data-density scope on the field wrapper (small/medium/large alias sm/default/lg; xs and 2xs stay directly addressable) AND co-stamps --jx-density-coefficient: 1 on the same root — an ACTIVE pin, not a formality: every channel composes base × coefficient inside the scope, so the pin stops any OUTER coefficient at the boundary and makes explicit rung = exact rung (an outer ×3 wrapper moves a pin-less box 24 → 72px; the pin holds the rung exact) — the scope block re-declares the channels AT the wrapper, and everything the family reads moves together: the box is var(--jx-icon) (the rung's line scale), the row's hit lane is var(--jx-hit) (an absolute floor term under the scale), the rhythm reads --jx-gap/--jx-text/--jx-line. The NUMBER lane is inert here: it stamps --jx-density-coefficient on the wrapper, but custom-property substitution runs at the DECLARING element — the channels are declared at :root and the rung scopes, never at this stamp — so nothing re-declares and nothing scales (measured: coefficient 1.5 leaves the 20px box, the lane, and the label byte-unmoved; the declaring-element law). auto stamps neither half — the ambient scope keeps flowing. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | Stamps --jx-color-effective, supplied downward. The paint reads the raw token layer directly — --primary (the fill and the hover border), --primary-foreground (the glyph), --background/--border (the shell), --ring (focus) — and the checkbox chain has zero --jx-color-effective readers (grep receipt): the fill keeps the theme's primary inside any island. Supply-only on this family. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | A FULL re-theme on this family, measured by the declaring-selector grep (the drift ledger's W-next #1 lens): every voice the paint reads — --background, --border, --primary, --primary-foreground, --ring, --foreground — is declared on the raw token layer (:root,.jx-light then .dark), and the chain reads no stylex-frozen semantic ink. dark stamps the .dark class bridge on the field wrapper and all of it flips (the primary hue drifts −4°, the shell inverts). bare renders no carrier element: ambient tree .dark still flows, an explicit theme="dark" does not (the next section). |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | Stamps --jx-elevation-effective (level-1…level5 are exact dp; a number snaps down to the enclosing rung). The control is flat by design — no shadow voice exists in the chain. Supply-only. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | Stamps --jx-motion-effective; zero readers. The family's real motion posture is fixed: 150ms ease-out on the fill/border/glyph morph, dropped entirely under prefers-reduced-motion: reduce — the css media query keys on the platform preference, not this axis. Supply-only. |
checkbox · on the axes
<script lang="ts">
import Checkbox from '@ui/checkbox';
</script>
<div class={cx(rt.col16, rt.wFull)}>
<!-- density: the NAMED lane paints on this family — the
rung stamps the data-density scope on the field
wrapper, the scope block re-declares the channels AT
the wrapper, and the box, the hit lane, the gap and
the label voice all move together. (The NUMBER lane
stamps only a coefficient — inert here: substitution
runs at the declaring element, so nothing re-declares
at the stamp.) -->
<div class={cx(rt.gridSm2)}>
<div class={cx(rt.panel)}><Checkbox label="lg rung — box and lane step up" name="axes-d-lg" density="lg" /></div>
<div class={cx(rt.panel)}><Checkbox label="2xs rung — the floor lowers to 24px" name="axes-d-2xs" density="2xs" /></div>
</div>
</div>checkbox · query()
Media keys are min-width: below 40rem the base applies — the large rung, the 24px box under a 48px lane (touch); at 40rem and wider the sm case wins — the compact 18px box in a 32px lane (pointer). Resize the window.
<script lang="ts">
import Checkbox from '@ui/checkbox';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<div class={cx(rt.col16, rt.wFull, rt.maxWXl)}>
<Checkbox
label="responsive hit lane"
name="axes-query"
density={query<{ sm: DensityLane }, DensityLane>({ sm: 'small' }, 'large')}
/>
<p class={cx(rt.para)}>
Media keys are min-width: below 40rem the base applies — the large rung, the
24px box under a 48px lane (touch); at 40rem and wider the sm case wins — the
compact 18px box in a 32px lane (pointer). Resize the window.
</p>
</div>The supply-only rows are documented absences, not gaps: the box is deliberately
square (border-radius: 0 — the glyph's design), its corner-shape reads var(--corner-shape, bevel) — an undeclared customization seam whose bevel
fallback wins today; the axis never bridges it — the fill is the
theme's raw --primary, and the control ships no shadow and no motion
kernel — so size, shape, radius, color, elevation, and motion stamp their carriers
and feed nested consumers without repainting this control. Checkbox is a batch A
native-collision family (explicit-props' migration-census.md, the §1 rule in
action): the Props interface omits the native size/color attributes and
re-declares the names as axis lanes — the destructured prop wins, the element never
receives them, and everything the family does not own still rides {...rest} to
the input (name, value, required, disabled…).
| Token | Default | Source |
|---|---|---|
| --jx-icon | the box (width/height) — the rung's line scale; 14 / 16 / 18 / 20 / 24px (2xs → lg, measured) | density |
| --jx-hit | the lane's min-block-size — max(floor, content); 24 / 28 / 32 / 40 / 48px (2xs → lg, measured) | density |
| --jx-gap | 8 / 8 / 8 / 12 / 16px (2xs → lg) | density |
| --jx-text / --jx-line | 10 / 11 / 12 / 13 / 15px · 14 / 16 / 18 / 20 / 24px (2xs → lg) | density |
| --jx-density-coefficient | 1 at :root; re-pinned to 1 by every named rung (an active pin inside the scope: it stops outer coefficients at the boundary) | density |
| border | 1px solid var(--border) | structural |
| glyph inset | 2px (the ::before) | structural |
| transition | 150ms ease-out; none under prefers-reduced-motion | structural |
bare
The bare branch — the unstamped embedded lane
bare renders the SINGLE input: the component's paint class, nothing else. No field wrapper means no data-density scope, no carrier style attr, no .dark class bridge, no query anchor — the explicit eight resolve and stamp nowhere. Ambient tree scopes still flow, because custom properties inherit; that is exactly the posture prose contexts want.
The branch exists for markdown task items (the Owner's 2026-09-08 markdown unlock
ruling: use the real component). In a rendered task list the source text owns the
state — the marker is presentation-only and disabled. A direct-child input keeps the
container-level DOM-shape laws working that the wrapped div > span > input shape defeats: markdown's [data-jx-markdown] li:has(> input[type='checkbox']) marker suppression and the list family's middle-alignment rules key on the input being
the li's direct child.
The stamps are the difference. In the demo below, density="lg" on the bare
input resolves the lane, supplies it to a context with no descendants, and paints
nothing — the box is byte-identical to the plain bare input. The wrapped contrast cell
passes the same density="lg" and shows where the stamps land: its root
carries data-density="lg" plus the carrier style, and the box steps up to
the lg rung. The paint law is the same either way — .jx-html-checkbox is one
css-laws vocabulary, and the indeterminate effect, the checked binding, and the
rest forwarding bind the same element in both branches.
checkbox · bare
<script lang="ts">
import Checkbox from '@ui/checkbox';
</script>
<div class={cx(rt.gridSm3, rt.wFull)}>
<div class={cx(rt.panel)}>
<span class={cx(rt.eyebrowPrimary)}>bare — one input</span>
<Checkbox bare checked name="bare-plain" />
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.eyebrowPrimary)}>bare + density lg — inert</span>
<Checkbox bare checked density="lg" name="bare-inert" />
</div>
<div class={cx(rt.panel)}>
<span class={cx(rt.eyebrowPrimary)}>wrapped + density lg — the stamps live here</span>
<Checkbox checked label="wrapped" name="wrapped-contrast" density="lg" />
</div>
</div>a11y
Accessibility
No ARIA synthesis: the element is a real checkbox, so the platform owns the semantics. The label rides label[for] (the id auto-generates), the error wires invalid + describedby, and the hit lane carries a 28px absolute floor under the density scale.
Keyboard
| Key | Action |
|---|---|
| Space | Toggle the focused checkbox — native behavior, the element is a real input |
| label click | Toggles too — the label is a label[for] on the same row (the id auto-generates), so the whole lane is the target |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | (native) | None synthesized — the native checkbox semantics ship with the element; :indeterminate exposes as mixed to assistive tech (platform behavior) |
| aria-invalid | true | Set when error is present |
| aria-describedby | {id}-error | References the validation message — a real paragraph headed by an aria-hidden ! mark |
The hit floor: the check lane's min-block-size is var(--jx-hit) — a
max() whose floor term is absolute while the content term scales with the rung: the
default floor is 28px (--jx-hit-floor), the 2xs scope deliberately LOWERS it
to 24px (the WCAG 2.5.8 AA note for pointer-dense tool surfaces), and the rungs
measure 24 / 28 / 32 / 40 / 48px (2xs → lg). The label[for] wiring widens the
effective target across the whole lane, not just the 20px glyph. disabled is
the native attribute riding the rest (opacity .5 + not-allowed), and prefers-reduced-motion: reduce drops the glyph morph transitions entirely —
the motion posture keys on the platform preference, not the motion axis.