Skip to content
Download ZIP

Neutral palette · 6.9 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_checkbox_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_checkbox_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Neutral
Version
1.0.0
Digest
Full digest
sha256-fdca18b0d2b7b7fbfdfa8ebfb7843d533d78b24d4f1309c9c23f3efe68aa5fa1
Checkbox.svelte Svelte · 10.5 KB Raw
<!--
	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>

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.

Suggested location
src/lib/components/checkbox-01
Required props
label

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.

Example

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>

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.

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))}
/>

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label, rendered as a <label for> the input. Clicking it toggles the box.
checkedbooleanNofalseChecked state. Bindable.
indeterminatebooleanNofalseMixed state for a select-all row: draws the dash and sets the input's indeterminate property. Bindable; cleared when the visitor toggles.
descriptionstring | SnippetNoNoneHelp text under the label, outside the click target, linked with aria-describedby. A snippet when it holds a link.
namestringNoNoneForm field name.
valuestringNoNoneValue submitted when checked. The browser sends 'on' when omitted.
requiredbooleanNofalseNative required.
disabledbooleanNofalseNative disabled. Say why in the description.
errorstringNoNoneValidation message. When set, it renders under the help text with a mark, the input gets aria-invalid, and the message joins aria-describedby.
errorPrefixstringNo'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.
idstringNoNoneThe 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) => voidNoNoneCalled once with the new state each time the visitor toggles the box.
classstringNoNoneExtra classes for the root, such as a width or a margin.

Customization#

On this page

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.

Public CSS variables

VariableToken
--checkbox-accentaccent
--checkbox-on-accentonAccent
--checkbox-inkink
--checkbox-mutedmuted
--checkbox-hairlinehairline
--checkbox-borderborder
--checkbox-surfacesurface
--checkbox-errorerror

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Local interaction
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 22 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.