# Checkbox

> Native checkbox, and with role="switch" the same input as a switch: indeterminate, sizes, colors, label association and forms.

Page: https://svelte-lean.vercel.app/primitives/checkbox · Markdown: https://svelte-lean.vercel.app/primitives/checkbox.md · Index: https://svelte-lean.vercel.app/llms.txt

A native checkbox, styled, and with role="switch" the same input as a switch: a yes or no value submitted with a form, or a setting that applies at once.

> **Note** Give the checkbox `role="switch"` when toggling takes effect at once (below). For one choice among several, use a [Radio group](https://svelte-lean.vercel.app/primitives/radio-group.md); for a select-all box over a group, a [Checkbox group](https://svelte-lean.vercel.app/primitives/checkbox-group.md).

```svelte
<script lang="ts">
	import '@svelte-lean/styles/checkbox.css';

	let terms = $state(false);
	let updates = $state(true);
</script>

<div class="stack">
	<label>
		<input type="checkbox" name="terms" data-slean="checkbox" bind:checked={terms} />
		I agree to the terms
	</label>
	<label>
		<input type="checkbox" name="updates" data-slean="checkbox" bind:checked={updates} />
		Product updates by email
	</label>
	<label>
		<input type="checkbox" name="locked" data-slean="checkbox" checked disabled />
		Locked by an administrator
	</label>
</div>

<style>
	.stack {
		display: grid;
		gap: 0.75rem;
	}
</style>
```

Click the label or press Space. Works with page JavaScript disabled.

## Examples

### Switch

```svelte
<script lang="ts">
	import '@svelte-lean/styles/checkbox.css';
</script>

<!-- role="switch": a setting that applies at once. The value and keys stay the checkbox's. -->
<ul class="settings">
	<li>
		<div>
			<label for="marketing">Marketing emails</label>
			<p id="marketing-hint">Product news and release notes, once a month.</p>
		</div>
		<input
			id="marketing"
			type="checkbox"
			role="switch"
			data-slean="checkbox"
			aria-describedby="marketing-hint"
		/>
	</li>
	<li>
		<div>
			<label for="security">Security alerts</label>
			<p id="security-hint">New sign-ins and password changes. Always on.</p>
		</div>
		<input
			id="security"
			type="checkbox"
			role="switch"
			data-slean="checkbox"
			aria-describedby="security-hint"
			checked
			disabled
		/>
	</li>
	<li>
		<div>
			<label for="mentions">Mentions</label>
			<p id="mentions-hint">When someone mentions you in a comment.</p>
		</div>
		<input
			id="mentions"
			type="checkbox"
			role="switch"
			data-slean="checkbox"
			aria-describedby="mentions-hint"
			checked
		/>
	</li>
</ul>

<style>
	.settings {
		display: grid;
		margin: 0;
		padding: 0;
		list-style: none;
	}
	.settings li {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 1rem;
		padding-block: 0.75rem;
	}
	.settings li + li {
		border-top: 1px solid var(--slean-border);
	}
	.settings label {
		font-weight: 500;
	}
	.settings p {
		margin: 0.125rem 0 0;
		color: var(--slean-fg-muted);
		font-size: 0.875rem;
	}
</style>
```

role="switch" draws a track and announces a switch; a description joins the name through aria-describedby.

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/checkbox.css';

	const sizes = ['small', 'medium', 'large'];
	const colors = ['primary', 'secondary', 'error', 'warning', 'info', 'success', 'neutral'];
</script>

<!-- data-size: small, medium (the default) and large; data-color: the checked color. -->
{#each [undefined, 'switch'] as role}
	<div class="row">
		{#each sizes as size}
			<label><input type="checkbox" {role} data-slean="checkbox" data-size={size} checked /> {size}</label>
		{/each}
		{#each colors as color}
			<label><input type="checkbox" {role} data-slean="checkbox" data-color={color} checked /> {color}</label>
		{/each}
	</div>
{/each}
```

small, medium (the default) and large; data-color is the checked color, on the input or an ancestor.

### Select all

```svelte
<script lang="ts">
	// data-slean="checkbox-group" registers the select-all behavior with @svelte-lean/vite.
	import '@svelte-lean/styles/checkbox.css';
	import '@svelte-lean/styles/checkbox-group.css';

	const files = ['report.pdf', 'budget.xlsx', 'roadmap.md'];
	let chosen = $state(['report.pdf', 'roadmap.md']);
</script>

<fieldset data-slean="checkbox-group">
	<legend>Attachments</legend>
	<label>
		<!-- indeterminate is a property, not an attribute: Svelte sets it from script. -->
		<input
			type="checkbox"
			data-slean="checkbox"
			data-slean-part="all"
			checked={chosen.length === files.length}
			indeterminate={chosen.length > 0 && chosen.length < files.length}
		/>
		Select all ({chosen.length} of {files.length})
	</label>
	{#each files as file (file)}
		<label>
			<input type="checkbox" name="files" value={file} data-slean="checkbox" bind:group={chosen} />
			{file}
		</label>
	{/each}
</fieldset>
```

The checkbox-group behavior checks every member from the all box and shows their state on it; bind:group follows.

### Group

```svelte
<script lang="ts">
	import '@svelte-lean/styles/checkbox.css';

	let channels = $state(['email']);
</script>

<fieldset class="channels">
	<legend>Notify me by</legend>
	<label><input type="checkbox" data-slean="checkbox" bind:group={channels} value="email" /> Email</label>
	<label><input type="checkbox" data-slean="checkbox" bind:group={channels} value="sms" /> SMS</label>
	<label><input type="checkbox" data-slean="checkbox" bind:group={channels} value="push" /> Push</label>
</fieldset>
<p>Selected: {channels.join(', ') || 'none'}</p>

<style>
	.channels {
		display: flex;
		flex-wrap: wrap;
		gap: 1.25rem;
		margin: 0;
		padding: 0;
		border: 0;
	}

	.channels legend {
		margin-block-end: 0.5rem;
		padding: 0;
		font-weight: 600;
	}
</style>
```

### Description

```svelte
<script lang="ts">
	import '@svelte-lean/styles/checkbox.css';
</script>

<div class="option">
	<input
		id="backups"
		type="checkbox"
		name="backups"
		data-slean="checkbox"
		aria-describedby="backups-hint"
		checked
	/>
	<label for="backups">Nightly backups</label>
	<p id="backups-hint">A copy of every project at 02:00, kept for thirty days.</p>
</div>

<style>
	.option {
		display: grid;
		grid-template-columns: auto 1fr;
		gap: 0.25rem 0.5rem;
		align-items: center;
	}

	.option p {
		grid-column: 2;
		margin: 0;
		color: var(--slean-fg-muted);
		font-size: var(--slean-text-sm);
	}
</style>
```

### Required

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/checkbox.css';

	let sent = $state(false);
</script>

<!-- The browser blocks the submit and marks the box :user-invalid until it is checked. -->
<form
	class="signup"
	onsubmit={(event) => {
		event.preventDefault();
		sent = true;
	}}
>
	<label>
		<input type="checkbox" name="terms" data-slean="checkbox" required />
		I accept the terms of service
	</label>
	<button type="submit" data-slean="button">Create account</button>
	<p role="status">{sent ? 'Account created.' : ''}</p>
</form>

<style>
	.signup {
		display: grid;
		justify-items: start;
		gap: 0.75rem;
	}
</style>
```

Submit without checking the box: the browser stops the form and the border turns red.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/checkbox.css';
```

Wrap the input in its `<label>`, or link them with `for` and `id`. Put related checkboxes in a `<fieldset>` with a `<legend>`.

Add `role="switch"` for a setting that takes effect at once: the input is drawn as a track and thumb and announced as a switch; the value, Space and the form stay the checkbox’s.

Read the state with `bind:checked`, `bind:group` or `FormData`. The mixed state of a "select all" box is the `indeterminate` property, set from script; Checkbox group keeps it in step with the members.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<input type="checkbox">` | – | yes | data-slean="checkbox" marks it for the styles. State is :checked and :indeterminate. |
| root (switch) | `<input type="checkbox" role="switch">` | – | no | The same primitive drawn as a track and thumb and announced as a switch. The WebKit-only switch attribute is not used. |
| label | `<label> wrapping the input, or for and id` | – | yes | Related checkboxes go in a \<fieldset\> with a \<legend\>. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Space` | focus on the checkbox or switch | Toggles (native). Enter does not |
| `Tab`/`Shift+Tab` | anywhere | Moves focus (native) |

## Styling

`checkbox.css` sets `appearance: none` and draws the mark as an icon mask on `::before`, a bar for `:indeterminate`, an error border for `:user-invalid`, the track and thumb of `role="switch"` (the thumb moves along the inline axis), three sizes, and on coarse pointers an invisible hit area on `::after`. Under forced colors it uses the system colors. The tokens it reads and the states it targets:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-control-border` | `var(--slean-border-strong)` | border, background |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-control-bg` | `var(--slean-surface)` | background |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-icon-check` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color, background |
| `--slean-icon-minus` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E")` | mask-image |
| `--slean-error-main` | `oklch(52% 0.19 25)` | border-color, box-shadow |
| `--slean-control-border-disabled` | `var(--slean-border)` | border-color, box-shadow |
| `--slean-control-bg-disabled` | `var(--slean-muted)` | background |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color, background |
| `--slean-radius-full` | `9999px` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-elevation-1` | `0 1px 2px oklch(0% 0 0 / 0.08)` | box-shadow |
| `--slean-border-strong` | `var(--slean-neutral-8)` | background |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-leading` | `1.5` | line-height |
| `--slean-color-dark` | `var(--slean-primary-dark)` | background |
| `--slean-control-height-md` | `2.25rem` | inset |

State selectors the stylesheet targets, all from the platform or ARIA: `:checked`, `:disabled`, `:hover`, `:indeterminate`, `:user-invalid`, `[aria-invalid="true"]`, `[role="switch"]`.

Variant attributes: `data-size` (`small`, `large`).

## Controlled integration

The input is the state. Svelte reads and writes it with `bind:checked`, `bind:group` and `bind:indeterminate`, and a form reads it with `FormData`. The package mirrors nothing and dispatches no `slean:*` event for a checkbox.

`preferences.svelte`

```svelte
<script lang="ts">
	// The input is the state: bind:checked and bind:group read and write it, and a form reads
	// it on submit. No mirror, no event of the package.
	function save(event: SubmitEvent) {
		event.preventDefault();
		const data = new FormData(event.currentTarget as HTMLFormElement);
		console.log(data.get('terms'), data.getAll('channel'));
	}
</script>

<form onsubmit={save}>
	<label><input type="checkbox" name="terms" data-slean="checkbox" /> I agree to the terms</label>
	<label><input type="checkbox" name="channel" value="email" data-slean="checkbox" /> Email</label>
	<label><input type="checkbox" name="channel" value="sms" data-slean="checkbox" /> SMS</label>
	<button type="submit">Save</button>
</form>
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<input type="checkbox">`, `role="switch"`, `:checked`, `:indeterminate`, `:user-invalid`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<input type="checkbox">, role="switch"`

Tier 0: `packages/primitives/src/checkbox` has no behavior, validate or register module, the Vite plugin maps `checkbox` to no module, and the playground's bundle spec asserts that the native page, which includes this markup, loads no behavior runtime. Nothing is attached at hydration.

Why this implementation

The native checkbox has the checked, indeterminate and disabled states, Space to toggle, the label association, form participation, constraint validation and the accessible role and name. A `<div role="checkbox">` re-creates part of that in JavaScript and loses the form.

With `appearance: none` the native control can be drawn any way, so the reason to replace it is gone. Svelte Lean ships a stylesheet and a contract and nothing else; the state is read from the input or from `FormData`, never from a mirror.

### The browser owns

- the checked, indeterminate and disabled state
- Space to toggle and the label click
- form participation, required and the change event
- the accessible role (checkbox, or switch with role="switch"), name and state

### Svelte Lean owns

- checkbox.css: the drawn mark, the indeterminate bar, the switch track and thumb, three sizes, the coarse-pointer hit area
- the contract
- documentation

Accessibility

- An `<input type="checkbox">` with a label: a wrapping `<label>`, or `for` and `id`. With `role="switch"` screen readers announce a switch, on or off (ARIA 1.2).
- State is `:checked`, `:indeterminate`, `:disabled` and, with `required`, `:user-invalid`. No `aria-checked`; the native state is exposed.
- `disabled`: not focusable and not submitted. Related checkboxes are grouped by a `<fieldset>` with a `<legend>`.
- The indeterminate state is the `indeterminate` property, set from script; it is not an attribute and cannot be server-rendered.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `appearance: none, :checked, :indeterminate` | Widely available | Not applicable within the support policy |
| `:user-invalid` | Baseline 2023 | The invalid border is not drawn; the browser still reports the constraint |
| `role="switch" on a checkbox` | Defined by ARIA 1.2 and mapped by current screen readers | Announced as a checkbox where the role is not mapped |

Without JavaScript

Fully functional. The playground's native page runs the click, label and Space assertions with page JavaScript disabled. Only the indeterminate state, being a property, needs a script.

Server rendering and hydration

The `checked` attribute renders the initial state and forms work before hydration. Ids are authored when `for` is used.

The delayed-hydration test clicks a label and asserts the checkbox is checked while every script response is held back. An application that uses `bind:checked` follows Svelte's own hydration rules for bound inputs.

Compatibility

`appearance: none`, `:checked` and `:indeterminate` are Baseline widely available; `:user-invalid` is Baseline 2023 and only affects the invalid border. There is no fallback case for the control itself.

Tests and source

- `apps/playground/tests/primitives/native.spec.ts` Playwright, with page JavaScript enabled and disabled
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)

- `packages/primitives/src/checkbox/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/checkbox/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/checkbox.css` the optional stylesheet
