A styled native HTML checkbox that looks like the Checkbox and works without JavaScript.
Installation
Install the dependencies
Copy the component
Copy the following files into src/lib/components/ui/native-checkbox.
These files use the Nova style and Lucide icons. npx @sveltery/ui@latest add writes the style and icon library from your components.json.
<script lang="ts">
import type { NativeCheckboxProps } from './types.js';
import { cn } from 'cn';
import CheckIcon from '@lucide/svelte/icons/check';
let { class: classProp, ...props }: NativeCheckboxProps = $props();
</script>
<!-- The wrapper takes Base's `checkbox` slot, so a label or field that reads its peer's slot styles both alike. -->
<span data-slot="checkbox" class={cn('peer relative inline-grid shrink-0 *:col-start-1 *:row-start-1 has-disabled:opacity-50', classProp)}>
<input type="checkbox" data-slot="native-checkbox" class="border-input dark:bg-input/30 checked:bg-primary checked:text-primary-foreground dark:checked:bg-primary checked:border-primary aria-invalid:checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-checked:border-input group-has-[:focus-visible]/field-label:checked:border-primary peer m-0 appearance-none outline-none disabled:cursor-not-allowed" {...props} />
<span data-slot="checkbox-indicator" class="[&>svg]:size-3.5 pointer-events-none invisible grid place-content-center text-primary-foreground peer-checked:visible">
<CheckIcon />
</span>
</span> import type { HTMLInputAttributes } from 'svelte/elements';
export type NativeCheckboxProps = Omit<HTMLInputAttributes, 'type'> & { type?: 'checkbox' }; export { default as NativeCheckbox } from './NativeCheckbox.svelte';
export type { NativeCheckboxProps } from './types.js'; Update the import paths
The component folders import each other with relative paths, so keep them side by side. If you place them elsewhere, change the imports to match your project setup.
Usage
<script lang="ts">
import { NativeCheckbox } from '#lib/components/ui/native-checkbox';
import { Label } from '#lib/components/ui/label';
</script> <div class="flex items-center gap-2">
<NativeCheckbox id="terms" name="terms" defaultChecked />
<Label for="terms">Accept terms and conditions</Label>
</div> NativeCheckbox is a real <input type="checkbox">, and every prop goes on it. Mark the initial state with defaultChecked. A form reset returns to it, as it does for a native checkbox. checked sets the current state, which is what a remote form field passes.
The input sits in a <span> that draws the check icon over it. class goes on that <span>, which carries the Checkbox's data-slot="checkbox", so a Label or Field styles itself as it does next to a Checkbox. Style the box with checked:, disabled: and aria-invalid: on the input, through a selector such as [&_input]:.
When to use it
Use NativeCheckbox when the form must work before the page hydrates or with JavaScript off. The browser does all of the work:
- A click or tap on the checkbox or its label toggles it.
- Space toggles it, and each checkbox is one tab stop.
- A checked checkbox submits its
value, oron, with its form.
Checkbox runs on the Sveltery Base Checkbox, which renders a <span> and a hidden input. Without JavaScript, the keyboard does nothing there. Checkbox also gives you what NativeCheckbox does not have:
- Base UI state attributes, such as
data-checkedanddata-disabled, to style on. bind:checkedandonCheckedChange.- The indeterminate state, and a parent checkbox for a group.
- Base's Field behavior: validation,
data-dirtyanddata-touched, and Formerrors.
Group
For a group of checkboxes that submit one list, give each the same name and its own value, and put them in a FieldSet with a FieldLegend.
<FieldSet>
<FieldLegend variant="label">Show these items on the desktop:</FieldLegend>
{#each items as item (item.value)}
<Field orientation="horizontal">
<NativeCheckbox id="desktop-{item.value}" name="desktop" value={item.value} />
<FieldLabel for="desktop-{item.value}" class="font-normal">{item.label}</FieldLabel>
</Field>
{/each}
</FieldSet> Toggle style
To make choices look like a Toggle Group and still work without JavaScript, hide a plain checkbox inside a label that has the Toggle's classes. has-checked: styles the label from its checkbox's state. Use radios the same way for a single choice; see Native Radio Group.
<script lang="ts">
import { toggleVariants } from '#lib/components/ui/toggle';
import { cn } from 'cn';
const toggle = cn(toggleVariants({ variant: 'outline' }), 'cursor-pointer has-checked:bg-muted has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50');
</script>
<fieldset class="flex gap-1">
<legend class="sr-only">Text style</legend>
<label class={toggle}><input class="sr-only" type="checkbox" name="style" value="bold" />Bold</label>
<label class={toggle}><input class="sr-only" type="checkbox" name="style" value="italic" />Italic</label>
</fieldset> Remote forms
Spread a remote form field onto a checkbox with as('checkbox') for a boolean, or with as('checkbox', value) for each option of a list. The field gives the checkbox its name, value, checked state and aria-invalid. Native Switch and Native Slider take their fields the same way.
<script lang="ts">
import { Button } from '#lib/components/ui/button';
import { Field, FieldError, FieldLabel, FieldLegend, FieldSet } from '#lib/components/ui/field';
import { NativeCheckbox } from '#lib/components/ui/native-checkbox';
import { preferencesSchema, topics } from '#lib/forms/preferences';
import { savePreferences } from '#lib/forms/preferences.remote';
const f = savePreferences.preflight(preferencesSchema);
</script>
<form {...f}>
<FieldSet>
<FieldLegend variant="label">Email me about</FieldLegend>
{#each topics as topic (topic)}
<Field orientation="horizontal">
<NativeCheckbox id="topic-{topic}" {...f.fields.topics.as('checkbox', topic)} />
<FieldLabel for="topic-{topic}" class="font-normal">{topic}</FieldLabel>
</Field>
{/each}
<FieldError errors={f.fields.topics.issues()} />
</FieldSet>
<Field orientation="horizontal" data-invalid={!!f.fields.terms.issues()}>
<NativeCheckbox id="terms" {...f.fields.terms.as('checkbox')} />
<FieldLabel for="terms">Accept the terms</FieldLabel>
</Field>
<Button type="submit" disabled={f.pending > 0}>Save</Button>
</form> Without JavaScript, the form posts to the server, and the page comes back with the submitted checkboxes checked and the issues shown.