# Checkbox

> A native checkbox with a drawn box, a label, optional help text and an error, plus a dash for select-all rows. The box stays level with the label's first line however far it wraps.

- ID: `cmp_checkbox_01`
- Slug: `checkbox-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T05:23:32Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `selection`
- Detail page: https://pagesugar.com/components/checkbox-01
- Preview: https://pagesugar.com/preview/checkbox-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-fdca18b0d2b7b7fbfdfa8ebfb7843d533d78b24d4f1309c9c23f3efe68aa5fa1` |
| `blue` | Blue accent | no | `sha256-dde556f2221e5430ec673112d358f4cfcb8dd1b462cd05ba1678b1cee3defe43` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/checkbox-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass a label, and a name and value when it sits in a form. Bind checked, or listen with onCheckedChange. For a select-all row, compute indeterminate from the rows yourself and pass it; the checkbox shows the dash and clears it when the visitor toggles. It does not group checkboxes under a legend, manage a parent and child selection tree, decide when to show an error, or check consent wording.

Required props: `label`

```svelte
<script lang="ts">
	import Checkbox from '$lib/components/checkbox-01/Checkbox.svelte';

	let agreed = $state(false);
	let error = $state<string>();

	function submit(event: SubmitEvent) {
		error = agreed ? undefined : 'Tick the box to agree to the terms';
		if (error) event.preventDefault();
	}
</script>

<form method="post" novalidate onsubmit={submit} class="grid max-w-md gap-6">
	<Checkbox
		label="I agree to the terms of service"
		name="terms"
		value="accepted"
		required
		{error}
		bind:checked={agreed}
	/>
	<button type="submit">Create account</button>
</form>
```

Limitations:

- The indeterminate state is a DOM property, so it needs the browser. Before hydration the dash is drawn from the prop, but a click then toggles the box underneath it until the page hydrates.
- It shows the error you pass; it never validates. Set error after your own validation, usually on submit.
- One checkbox. Several related checkboxes need a fieldset and legend around them, with any group error on the fieldset.
- The box's hit area reaches 4 px past the box, and on coarse pointers 12 px above and below and 16 to 20 px into the start gutter. Keep that much room between a checkbox and other controls beside it.

## Usage guide

### Checkbox

One checkbox: a native `<input type="checkbox">` under a drawn box, a label, and optional help
text and error. The box sits in a column one line tall, so it stays level with the label's
first line however far the label wraps, and the label, help text and error share one text edge.

#### In a form

The input is a real form control. With `name` and `value` it posts `name=value` when ticked and
nothing when not, with or without JavaScript.

```svelte
<Checkbox label="Email me the weekly digest" name="digest" value="weekly" />
```

#### Select all

Compute the mixed state from the rows and pass it. The checkbox draws the dash, sets the input's
`indeterminate` property, and clears it when the visitor toggles; `onCheckedChange` then reports
the new state so you can select or clear every row.

```svelte
<script lang="ts">
	let rows = $state([
		{ id: 'a', selected: true },
		{ id: 'b', selected: false }
	]);
	const count = $derived(rows.filter((r) => r.selected).length);
</script>

<Checkbox
	label="Select all"
	size="sm"
	checked={count === rows.length}
	indeterminate={count > 0 && count < rows.length}
	onCheckedChange={(value) => rows.forEach((r) => (r.selected = value))}
/>
```

#### Help text with a link

Pass a snippet. The help text is outside the label, so the link opens without toggling the box.

```svelte
{#snippet help()}
	Guests can download every card. <a href="/docs/guests">What guests can see</a>
{/snippet}

<Checkbox label="Let guests export this board" description={help} />
```

#### Errors

Set `error` after your own validation, usually on submit, and clear it once the box is ticked.
The message renders under the help text with a mark and a hidden "Error:" prefix (translate it
with `errorPrefix`), the box takes a two-pixel edge and the input gets `aria-invalid`.

#### Spacing a stack

Stack checkboxes with a gap rather than margins: 20 px (`gap-5`) when they carry help text,
12 px (`gap-3`) when they do not. Several related checkboxes belong in a `<fieldset>` with a
`<legend>`.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | Visible label, rendered as a \<label for\> the input. Clicking it toggles the box. |
| `checked` | `boolean` | no | `false` | Checked state. Bindable. |
| `indeterminate` | `boolean` | no | `false` | Mixed state for a select-all row: draws the dash and sets the input's indeterminate property. Bindable; cleared when the visitor toggles. |
| `description` | string \| Snippet | no |  | Help text under the label, outside the click target, linked with aria-describedby. A snippet when it holds a link. |
| `name` | `string` | no |  | Form field name. |
| `value` | `string` | no |  | Value submitted when checked. The browser sends 'on' when omitted. |
| `required` | `boolean` | no | `false` | Native required. |
| `disabled` | `boolean` | no | `false` | Native disabled. Say why in the description. |
| `error` | `string` | no |  | Validation message. When set, it renders under the help text with a mark, the input gets aria-invalid, and the message joins aria-describedby. |
| `errorPrefix` | `string` | no | `'Error:'` | Visually hidden text read before the error. Translate it with the page. |
| `size` | 'sm' \| 'md' | no | `'md'` | md: 18 px box and 16 px label, for forms. sm: 16 px box and 14 px label, for settings panels and table rows. |
| `id` | `string` | no |  | The input's id. Generated per instance with $props.id() when omitted; the description and error ids add -description and -error to it. |
| `onCheckedChange` | `(checked: boolean) => void` | no |  | Called once with the new state each time the visitor toggles the box. |
| `class` | `string` | no |  | Extra classes for the root, such as a width or a margin. |

## Customization

Change the words through props and retone the box through eight --checkbox-\* CSS variables. Hover, disabled and focus tones are mixed from those, so they follow any retone.

- Accent: --checkbox-accent fills the checked and mixed box and colours the focus ring; --checkbox-on-accent draws the tick and dash on it. Keep the pair at 3:1 or more.
- Edge: --checkbox-border is the unchecked box's edge and must clear 3:1 against the page (the default is black at 44% on white). --checkbox-hairline is the disabled box's edge (black at 24% by default, light enough to read as disabled and still findable).
- Text: --checkbox-ink is the label, --checkbox-muted the help text. Links in the help text take the ink colour with an underline.
- Error: --checkbox-error colours the message and, while the box is unchecked, its two-pixel edge. The default is red-700 (6.5:1 on white); on a dark fill use a lighter red such as #f87171.
- Surface: --checkbox-surface is the unchecked box's fill. Set it to the page colour on a tinted page.
- Worked retone for a #09090b band: --checkbox-accent: #fafafa; --checkbox-on-accent: #09090b; --checkbox-ink: #fafafa; --checkbox-muted: #a1a1aa; --checkbox-border: rgb(255 255 255 / 0.45); --checkbox-hairline: rgb(255 255 255 / 0.24); --checkbox-surface: #09090b; --checkbox-error: #f87171.
- Size: size='md' for sign-up, booking and checkout forms; size='sm' for settings panels, filters and table rows.
- Spacing: stack checkboxes 20 px apart (gap-5) when they carry help text, 12 px when they do not, so each description groups with its own label.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--checkbox-accent` |
| `onAccent` | `--checkbox-on-accent` |
| `ink` | `--checkbox-ink` |
| `muted` | `--checkbox-muted` |
| `hairline` | `--checkbox-hairline` |
| `border` | `--checkbox-border` |
| `surface` | `--checkbox-surface` |
| `error` | `--checkbox-error` |

## Accessibility

- The input is a native checkbox, so Space toggles it, it posts with the form and the browser enforces required. The label is a \<label for\> the input and is its accessible name.
- indeterminate sets the input's indeterminate property, which browsers expose as mixed (aria-checked='mixed'). You compute it from the rows it summarises; the checkbox clears it when the visitor toggles.
- The help text and the error are outside the label and linked with aria-describedby (help text first). aria-invalid='true' is set only while error is present.
- The error is not a live region, so it is not announced twice. On submit, move focus to the first invalid field or an error summary yourself.
- The error carries a visually hidden prefix (translate it with errorPrefix), a mark and words, and an unchecked box takes a two-pixel edge, so it never rests on colour alone.
- Focus shows a two-pixel ring in the accent, two pixels outside the box. The box's hit area is at least 24 px, 44 px on coarse pointers, and the whole label toggles it too.
- Several related checkboxes need a fieldset and legend; the checkbox does not supply them. Disabled checkboxes leave the tab order, so say why in the description.
- In forced-colours mode the box keeps a system-colour edge and a checked or mixed box is painted in Highlight.

Known limitations:

- Before hydration, a click on an indeterminate checkbox toggles its checked state under the drawn dash.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `Checkbox.svelte`
- Suggested directory: `src/lib/components/checkbox-01`
- Files: 1
- Artifact digest: `sha256-fdca18b0d2b7b7fbfdfa8ebfb7843d533d78b24d4f1309c9c23f3efe68aa5fa1`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `Checkbox.svelte`

Role: entry · 10767 bytes · SHA-256 `8435621899424291066b0417ecb31bbfeea79e6265cc68cc392e490b6fe35e32`

```svelte
<!--
	One checkbox: a native input under a drawn box, a label, and an optional description and error.
	The box sits in a column one line tall, so it stays level with the label's first line however
	far the text wraps, and the label, description and error share one text edge beside it. The
	input stays a real form control (it posts name=value without JavaScript); only the indeterminate
	state needs the browser, since it is a DOM property, and the dash is drawn from the prop so the
	server render already shows it.
-->
<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** Bindable checked state. */
		checked?: boolean;
		/** Bindable mixed state for a select-all row. Cleared when the visitor toggles. */
		indeterminate?: boolean;
		/** Visible label; clicking it toggles the box. */
		label: string;
		/** Help text under the label, outside the click target. A snippet when it holds a link. */
		description?: string | Snippet;
		/** Form field name. */
		name?: string;
		/** Submitted value. The browser sends 'on' when omitted. */
		value?: string;
		/** Native required. */
		required?: boolean;
		/** Native disabled. */
		disabled?: boolean;
		/** Validation message. Set, the input gets aria-invalid and the message joins its description. */
		error?: string;
		/** Read before the error by assistive technology. */
		errorPrefix?: string;
		/** 'sm' for dense rows and settings, 'md' for forms. */
		size?: 'sm' | 'md';
		/** The input's id. Generated per instance when omitted. */
		id?: string;
		/** Called with the new state after the visitor toggles. */
		onCheckedChange?: (checked: boolean) => void;
		/** Extra classes for the root, such as a width or a margin. */
		class?: string;
	}

	let {
		checked = $bindable(false),
		indeterminate = $bindable(false),
		label,
		description,
		name,
		value,
		required = false,
		disabled = false,
		error,
		errorPrefix = 'Error:',
		size = 'md',
		id,
		onCheckedChange,
		class: className
	}: Props = $props();

	const uid = $props.id();
	const inputId = $derived(id || `${uid}-input`);
	const descriptionId = $derived(`${inputId}-description`);
	const errorId = $derived(`${inputId}-error`);
	const hasDescription = $derived(typeof description === 'function' || Boolean(description));
	const describedBy = $derived(
		[hasDescription && descriptionId, error && errorId].filter(Boolean).join(' ') || undefined
	);

	let input: HTMLInputElement | undefined = $state();

	/* indeterminate is a property, not an attribute, so it can only be set in the browser. */
	$effect(() => {
		if (input) input.indeterminate = indeterminate;
	});

	/* bind:checked keeps the state in step through hydration and form resets; this reports a toggle. */
	function change(event: Event & { currentTarget: HTMLInputElement }) {
		checked = event.currentTarget.checked;
		indeterminate = false;
		onCheckedChange?.(checked);
	}

	const sm = $derived(size === 'sm');

	/*
	 * The box's tone, one class set per state so no two variants fight over a property. Checked
	 * follows the input through peer-checked, so it is right before hydration too; indeterminate
	 * and disabled come from props.
	 */
	const boxTone = $derived(
		disabled
			? indeterminate
				? 'border-transparent bg-(--_disabled-fill)'
				: 'border-(--_hairline) bg-(--_raised) peer-checked:border-transparent peer-checked:bg-(--_disabled-fill)'
			: indeterminate
				? 'border-(--_fill) bg-(--_fill)'
				: [
						error
							? 'border-(--_error) bg-(--_surface) shadow-(--_error-edge)'
							: 'border-(--_edge) bg-(--_box)',
						'peer-checked:border-(--_fill) peer-checked:bg-(--_fill) peer-checked:shadow-none'
					].join(' ')
	);
</script>

<div
	class={[
		'checkbox grid grid-cols-[auto_minmax(0,1fr)] text-start',
		sm ? 'gap-x-2 text-sm/5' : 'gap-x-3 text-base/6',
		className
	]}
	data-disabled={disabled ? '' : undefined}
>
	<!-- One line tall, so the box centres on the label's first line. -->
	<span class="checkbox__control relative grid h-lh place-items-center">
		<input
			bind:this={input}
			id={inputId}
			type="checkbox"
			class={[
				'checkbox__input peer absolute m-0 cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed',
				/*
				 * The hit area outgrows the drawn box: 24 px or more, and 44 px or more on a coarse
				 * pointer, where it grows into the start gutter and stops at the text column.
				 */
				'-inset-1 pointer-coarse:-inset-y-3',
				sm
					? 'pointer-coarse:-start-5 pointer-coarse:-end-2'
					: 'pointer-coarse:-start-4 pointer-coarse:-end-3'
			]}
			bind:checked
			{name}
			{value}
			{required}
			{disabled}
			aria-describedby={describedBy}
			aria-invalid={error ? 'true' : undefined}
			data-indeterminate={indeterminate ? '' : undefined}
			onchange={change}
		/>
		<span
			class={[
				'checkbox__box col-start-1 row-start-1 rounded-sm border',
				sm ? 'size-4' : 'size-4.5',
				boxTone,
				'outline-offset-2 outline-(--_accent) peer-focus-visible:outline-2',
				'transition-[background-color,border-color,box-shadow,scale] duration-150 ease-(--_ease)',
				/* The press lands in 80 ms; the release and every tone change take 150. */
				!disabled && 'peer-active:duration-[80ms]',
				!disabled && 'peer-active:scale-90 motion-reduce:peer-active:scale-100',
				'motion-reduce:transition-none'
			]}
			aria-hidden="true"
		></span>
		<svg
			class={[
				'checkbox__mark pointer-events-none col-start-1 row-start-1',
				sm ? 'size-4' : 'size-4.5',
				disabled ? 'text-(--_surface)' : 'text-(--_on-accent)',
				indeterminate
					? 'opacity-100'
					: 'scale-50 opacity-0 peer-checked:scale-100 peer-checked:opacity-100 motion-reduce:scale-100',
				'transition-[opacity,scale] duration-150 ease-(--_ease)'
			]}
			viewBox="0 0 16 16"
			fill="none"
			stroke="currentColor"
			stroke-width="2"
			stroke-linecap="round"
			stroke-linejoin="round"
			aria-hidden="true"
		>
			{#if indeterminate}
				<path d="M4.5 8h7" />
			{:else}
				<path d="M4 8.25 6.75 11 12 5" />
			{/if}
		</svg>
	</span>

	<label
		for={inputId}
		class={[
			'checkbox__label text-pretty break-words',
			'font-medium',
			disabled ? 'cursor-not-allowed text-(--_muted)' : 'cursor-pointer text-(--_ink)'
		]}
	>
		{label}
	</label>

	{#if hasDescription}
		<div
			id={descriptionId}
			class={[
				'checkbox__description col-start-2 mt-1 text-pretty break-words',
				disabled ? 'text-(--_faint)' : 'text-(--_muted)',
				sm ? 'text-xs/4' : 'text-sm/5'
			]}
		>
			{#if typeof description === 'function'}
				{@render description()}
			{:else}
				{description}
			{/if}
		</div>
	{/if}

	{#if error}
		<p
			id={errorId}
			class={[
				'checkbox__error col-start-2 mt-1 flex gap-2 font-medium text-(--_error)',
				sm ? 'text-xs/4' : 'text-sm/5'
			]}
		>
			<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
				<svg
					class={sm ? 'size-3.5' : 'size-4'}
					viewBox="0 0 16 16"
					fill="none"
					stroke="currentColor"
					stroke-width="1.75"
					stroke-linecap="round"
					stroke-linejoin="round"
				>
					<circle cx="8" cy="8" r="6.25" />
					<path d="M8 4.75v3.75" />
					<path d="M8 11.25h.01" />
				</svg>
			</span>
			<span class="min-w-0 text-pretty break-words">
				<span class="sr-only">{errorPrefix}</span>
				{error}
			</span>
		</p>
	{/if}
</div>

<style>
	/* Public tokens: set --checkbox-* on the checkbox or any ancestor to retone it. */
	.checkbox {
		--_accent: var(--checkbox-accent, #18181b);
		--_on-accent: var(--checkbox-on-accent, #ffffff);
		--_ink: var(--checkbox-ink, #18181b);
		--_muted: var(--checkbox-muted, #52525b);
		/* The disabled box's edge: lighter than the border, still enough to find the box. */
		--_hairline: var(--checkbox-hairline, rgb(0 0 0 / 0.24));
		/* A control boundary, not a hairline: 3:1 against white. */
		--_border: var(--checkbox-border, rgb(0 0 0 / 0.44));
		--_surface: var(--checkbox-surface, #ffffff);
		--_error: var(--checkbox-error, #b91c1c);

		/* Formulas: hover and disabled tones mixed from the tokens, so a retone carries them. */
		--_edge: var(--_border);
		--_box: var(--_surface);
		--_fill: var(--_accent);
		--_raised: color-mix(in oklab, var(--_ink) 4%, var(--_surface));
		/* Disabled: a fill part-way to the page, and help text a step below its label. */
		--_disabled-fill: color-mix(in oklab, var(--_ink) 28%, var(--_surface));
		--_faint: color-mix(in oklab, var(--_muted) 78%, var(--_surface));
		--_error-edge: inset 0 0 0 1px var(--_error);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/*
	 * Hover darkens the edge, or the fill once checked, whether the pointer is on the box or the
	 * label: both toggle it. A relation between siblings, so it lives here, inside the hover guard.
	 */
	@media (hover: hover) {
		.checkbox:not([data-disabled]):has(.checkbox__input:hover, .checkbox__label:hover) {
			--_edge: color-mix(in oklab, var(--_border), var(--_ink) 70%);
			--_box: var(--_raised);
			--_fill: color-mix(in oklab, var(--_accent) 90%, var(--_surface));
		}
	}

	/* Help text is consumer content: links in it read as links and keep their own focus ring. */
	@layer components {
		.checkbox__description :global(:where(a)) {
			color: var(--_ink);
			font-weight: 500;
			text-decoration-line: underline;
			text-decoration-thickness: 1px;
			text-underline-offset: 3px;
			text-decoration-color: color-mix(in oklab, var(--_ink) 35%, transparent);
			border-radius: 2px;
			transition: text-decoration-color 150ms var(--_ease);
		}

		.checkbox__description :global(:where(a:focus-visible)) {
			outline: 2px solid var(--_accent);
			outline-offset: 2px;
		}

		@media (hover: hover) {
			.checkbox__description :global(:where(a:hover)) {
				text-decoration-color: currentColor;
			}
		}
	}

	/* Forced colours drop the drawn fill; paint a checked or mixed box in system colours instead. */
	@media (forced-colors: active) {
		.checkbox__box {
			border-color: CanvasText;
		}

		.checkbox__input:is(:checked, [data-indeterminate]) + .checkbox__box {
			forced-color-adjust: none;
			border-color: Highlight;
			background-color: Highlight;
		}

		/* Adjustment is off on that box, so its focus ring needs a system colour of its own. */
		.checkbox__input:focus-visible + .checkbox__box {
			outline-color: CanvasText;
		}

		.checkbox__input:is(:checked, [data-indeterminate]) ~ .checkbox__mark {
			forced-color-adjust: none;
			color: HighlightText;
		}

		.checkbox__input:disabled + .checkbox__box,
		.checkbox__input:disabled:is(:checked, [data-indeterminate]) + .checkbox__box {
			border-color: GrayText;
			background-color: Canvas;
		}

		.checkbox__input:disabled ~ .checkbox__mark,
		.checkbox[data-disabled] :is(.checkbox__label, .checkbox__description) {
			color: GrayText;
		}
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-fdca18b0d2b7b7fbfdfa8ebfb7843d533d78b24d4f1309c9c23f3efe68aa5fa1`
- Entry: `Checkbox.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_checkbox_01/1.0.0/neutral/sha256-fdca18b0d2b7b7fbfdfa8ebfb7843d533d78b24d4f1309c9c23f3efe68aa5fa1/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_checkbox_01/1.0.0/neutral/sha256-fdca18b0d2b7b7fbfdfa8ebfb7843d533d78b24d4f1309c9c23f3efe68aa5fa1/bundle.zip (7027 bytes, sha256 `e9f9e53a7378851e1f5b258aba316f01455d1c0987ff535b40a8bdb8546ca276`)

Files:

- `Checkbox.svelte` (entry, 10767 bytes): https://pagesugar.com/artifacts/cmp_checkbox_01/1.0.0/neutral/sha256-fdca18b0d2b7b7fbfdfa8ebfb7843d533d78b24d4f1309c9c23f3efe68aa5fa1/source/Checkbox.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-dde556f2221e5430ec673112d358f4cfcb8dd1b462cd05ba1678b1cee3defe43`
- Entry: `Checkbox.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_checkbox_01/1.0.0/blue/sha256-dde556f2221e5430ec673112d358f4cfcb8dd1b462cd05ba1678b1cee3defe43/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_checkbox_01/1.0.0/blue/sha256-dde556f2221e5430ec673112d358f4cfcb8dd1b462cd05ba1678b1cee3defe43/bundle.zip (7033 bytes, sha256 `436b54a76f181b83ab1bdc17f3230a4518bcdaf2d2bd2eae4799af62514b3423`)

Files:

- `Checkbox.svelte` (entry, 10767 bytes): https://pagesugar.com/artifacts/cmp_checkbox_01/1.0.0/blue/sha256-dde556f2221e5430ec673112d358f4cfcb8dd1b462cd05ba1678b1cee3defe43/source/Checkbox.svelte
