# Checkbox group

> Native checkboxes in a fieldset with a select-all box: one shared change listener checks every member and shows their state as checked, mixed or unchecked, nested groups included.

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

Native checkboxes with a select-all box that checks them all and shows whether all, some or none are checked.

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

	const all = ['mentions', 'replies', 'digest'];
	let notify = $state(['mentions']);
</script>

<fieldset data-slean="checkbox-group">
	<legend>Notifications</legend>
	<label>
		<!-- Server-render the state of the all box; indeterminate is a property Svelte sets. -->
		<input
			type="checkbox"
			data-slean="checkbox"
			data-slean-part="all"
			checked={notify.length === all.length}
			indeterminate={notify.length > 0 && notify.length < all.length}
		/>
		All notifications
	</label>
	<label>
		<input type="checkbox" name="notify" value="mentions" data-slean="checkbox" bind:group={notify} />
		Mentions
	</label>
	<label>
		<input type="checkbox" name="notify" value="replies" data-slean="checkbox" bind:group={notify} />
		Replies
	</label>
	<label>
		<input type="checkbox" name="notify" value="digest" data-slean="checkbox" bind:group={notify} />
		Weekly digest
	</label>
</fieldset>
<p>Value: {notify.join(', ') || 'none'}</p>
```

Press the all box, then clear one member: the all box turns mixed and bind:group follows both.

## Examples

### Nested groups

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

<!-- A nested group is a member of the outer one through its own all box. -->
<fieldset data-slean="checkbox-group">
	<legend>Permissions</legend>
	<label><input type="checkbox" data-slean="checkbox" data-slean-part="all" /> Everything</label>
	<label><input type="checkbox" name="perm" value="read" data-slean="checkbox" /> Read</label>
	<fieldset data-slean="checkbox-group">
		<legend>Write</legend>
		<label>
			<input type="checkbox" data-slean="checkbox" data-slean-part="all" /> All writes
		</label>
		<label><input type="checkbox" name="perm" value="edit" data-slean="checkbox" /> Edit</label>
		<label><input type="checkbox" name="perm" value="delete" data-slean="checkbox" /> Delete</label>
	</fieldset>
</fieldset>
```

Edit one write permission: both all boxes turn mixed. Everything checks the whole tree.

### Disabled members, one row

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

<!-- A disabled member keeps its state: the all box skips it and leaves it out of its own. -->
<fieldset data-slean="checkbox-group" data-slean-orientation="horizontal">
	<legend>Exports</legend>
	<label><input type="checkbox" data-slean="checkbox" data-slean-part="all" /> All formats</label>
	<label><input type="checkbox" name="format" value="csv" data-slean="checkbox" /> CSV</label>
	<label><input type="checkbox" name="format" value="xlsx" data-slean="checkbox" /> Excel</label>
	<label>
		<input type="checkbox" name="format" value="pdf" data-slean="checkbox" disabled />
		PDF (upgrade)
	</label>
</fieldset>
```

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="checkbox-group"`; without it, import the register module once.

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

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/checkbox-group/register';
```

`stylesheets`

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

Put `data-slean="checkbox-group"` on a `<fieldset>` with a `<legend>`, and mark the select-all checkbox `data-slean-part="all"`. Leave the name off it: it is not a form value. Draw the checkboxes with `data-slean="checkbox"` and import `checkbox.css`.

Render the all box’s state from the template when you can (`checked` when every member is, Svelte’s `indeterminate={…}` for some); otherwise the behavior sets it on the first focus in the group. Nest a group inside another for a tree of permissions.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<fieldset data-slean="checkbox-group"> with a <legend>` | – | yes | data-slean-orientation="horizontal" puts the members on one row. |
| all | `<input type="checkbox"> without a name` | `all` | yes | Checks or clears every enabled member; shows their state. Not a form value. |
| members | `<input type="checkbox" name="…" value="…">` | – | yes | The other checkboxes of the group; a nested group counts through its own all box. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Space` | focus on a checkbox | Toggles it (native) |
| `Tab`/`Shift+Tab` | in the group | Moves between the checkboxes |

## Styling

`checkbox-group.css` lays out the fieldset (the members indented under the all box, or one row with `data-slean-orientation="horizontal"`); the boxes are `checkbox.css`. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, margin-inline-start |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | margin-block-end |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | column-gap |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`.

Variant attributes: `data-slean-orientation` (`horizontal`).

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1456 B brotli · 1638 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<fieldset>`, `<input type="checkbox">`, `indeterminate`, `:indeterminate`
- **Shared listeners**: change, focusin, reset
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<fieldset> + <input type="checkbox">, indeterminate`

Tier 1: shared `change`, `focusin` and `reset` listeners on the document; no per-group listener and no state. Without the module the checkboxes still work and submit; only the all box stops following.

Why this implementation

A select-all box is the one thing a set of native checkboxes cannot do alone: it has to set the others and show their sum, including the mixed state, which HTML cannot write in markup. Every admin table and settings page has one.

The group stays native: every checkbox keeps its value, Space, the label click and the form value, and `bind:group` keeps working. One shared `change` listener, written once for every group on the page, checks the enabled members from the all box (dispatching `input` and `change` on each, as a click would) and sets the all box from the members. There is no state to keep: the checkboxes are the state.

### The browser owns

- every checkbox: its checked state, Space, the label click, the form value
- the mixed state of the all box (the indeterminate property, exposed as mixed)
- the group name from the fieldset’s legend

### Svelte Lean owns

- checking or clearing every enabled member from the all box, with input and change on each
- the all box’s checked, mixed or unchecked state from the members, nested groups included
- checkbox-group.css: the fieldset layout and the indent under the all box

Accessibility

- The legend names the group; every checkbox keeps its own label.
- The mixed state is the native `indeterminate` property, which screen readers announce as mixed or partially checked; no `aria-checked` is written.
- The behavior never moves focus. Disabled members are left as they are and are not counted.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<fieldset>, <legend>, the indeterminate property, :indeterminate` | Widely available | Not applicable within the support policy |

Without JavaScript

Every checkbox works and submits. The all box toggles only itself and submits nothing (it has no name).

Server rendering and hydration

Every checkbox renders its `checked` attribute; render the all box `checked` when every member is. The mixed state needs script: Svelte sets it during hydration, or the behavior on the first focus.

Nothing is attached per group. The first change or focus in a group is handled by the document listeners.

Compatibility

`<fieldset>`, `<legend>` and the `indeterminate` property are widely available.

Tests and source

- `packages/primitives/tests/checkbox-group.test.ts` Vitest: all and members, disabled members, nested groups, focus and reset, 1000 groups
- `apps/playground/tests/primitives/fields.spec.ts` Playwright, with page JavaScript enabled and disabled, and axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

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