sveltelean Primitives
Versionv0.2.0 GitHub

Example

Switches
<label>
	<input type="checkbox" role="switch" name="notifications" data-slean="switch" />
	Email notifications
</label>
<label>
	<input type="checkbox" role="switch" name="digest" data-slean="switch" checked />
	Weekly digest
</label>
<label>
	<input type="checkbox" role="switch" name="preview" data-slean="switch" disabled />
	Preview features
</label>
Tier 0: the two sources differ only by the stylesheet import. The track is the input and the thumb is a pseudo-element moved along the inline axis, so it mirrors under RTL. Everything here works with page JavaScript disabled.

Why this implementation exists

A switch is a checkbox whose change takes effect at once, and assistive technology announces it as "on" or "off" rather than "checked". ARIA 1.2 defines role="switch" for exactly that, and current screen readers map it on a native checkbox. Nothing else differs: Space toggles, the label names it, the form submits it, :checked styles it. Svelte Lean therefore ships a stylesheet that draws a track and a thumb, a contract that says when a switch is the right control, and no runtime, no aria-checked mirror and no click handler.

The browser owns

  • the checked and disabled state
  • Space to toggle and the label click
  • form participation and the change event
  • the switch role announcement (ARIA 1.2)

Svelte Lean owns

  • switch.css: track, thumb, motion tokens, the coarse-pointer hit area
  • the contract
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

npm install @svelte-lean/styles
app.css or +layout.svelte
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/switch.css';

Use a switch when toggling has an immediate effect: a setting that applies as soon as it is flipped. Use a Checkbox for a value submitted with a form. Do not use the WebKit-only switch attribute; it is not part of any standard.

Anatomy

PartElementdata-slean-partRequiredNotes
root<input type="checkbox" role="switch">–yesdata-slean="switch" marks it for the styles. The WebKit-only switch attribute is not used.
label<label> wrapping the input, or for and id–yesNames the switch.

Runtime profile

The runtime block reads the tier and the events from the contract and the bytes from the native-only consumer fixture: a production Vite build with the Vite plugin whose module graph contains no @svelte-lean/core or @svelte-lean/primitives module. The fixture's markup is button, dialog and popover; the switch path is proven the same way by construction: packages/primitives/src/switch has no behavior, validate or register module, the plugin maps switch 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.

Accessibility contract

  • An <input type="checkbox" role="switch"> with a label: a wrapping <label>, or for and id.
  • aria-checked is not needed: the native checked state is exposed through the role.
  • disabled: not focusable and not submitted.
  • Text direction is native; the thumb moves along the inline axis with logical properties in the stylesheet.

Keyboard

KeyWhenResult
Spacefocus on the switchToggles (native). Enter does not, as for any checkbox
Tab/Shift+TabanywhereMoves focus (native)

Platform features

FeatureBaselineOutside the target
role="switch" on a checkboxDefined by ARIA 1.2 and mapped by current screen readersAnnounced as a checkbox where the role is not mapped
appearance: none, :checkedWidely availableNot applicable within the support policy

Without JavaScript

Fully functional. The playground's native page runs the Space and label assertions with page JavaScript disabled. What is missing without a script is the immediate effect the application would attach to change, which is an application concern.

Server rendering

The checked attribute renders the initial state. Ids are authored when for is used.

Before hydration

The delayed-hydration test clicks the switch and asserts it is checked while every script response is held back. Hydration attaches nothing to an input the package does not bind.

Styling

switch.css sets appearance: none, draws the track on the input and the thumb on ::before, moves the thumb with inset-inline-start, and on coarse pointers adds an invisible hit area on ::after. The motion uses the duration tokens, which the tokens file sets to zero under prefers-reduced-motion, so the thumb stops animating without an extra rule. The tokens it reads and the states it targets:

TokenDefault (light)Applies to
--slean-radius-full9999pxborder-radius
--slean-control-bordervar(--slean-border-strong)background
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-surfaceoklch(100% 0 0)background
--slean-shadow-sm0 1px 2px oklch(0% 0 0 / 0.08)box-shadow
--slean-accentoklch(54% 0.19 258)background
--slean-control-bg-disabledvar(--slean-muted)background
--slean-control-border-disabledvar(--slean-border)box-shadow
--slean-border-strongvar(--slean-neutral-8)background
--slean-space-20.5remgap
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-leading1.5line-height
--slean-fg-mutedvar(--slean-neutral-11)color, background
--slean-accent-hoveroklch(49% 0.19 258)background
--slean-control-height-md2.25reminset

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

Controlled integration

The input is the state. Svelte reads and writes it with bind:checked; the application acts on the native change event because a switch takes effect at once. The package mirrors nothing and dispatches no slean:* event for a switch; a Svelte adapter is not built and is not needed for this primitive.

notifications.svelte
<script lang="ts">
	// A switch takes effect immediately, so the application acts on change rather than on
	// submit. The checked state stays in the input; bind:checked reads it.
	let notifications = $state(true);

	function onchange() {
		void fetch('/api/preferences', {
			method: 'PATCH',
			body: JSON.stringify({ notifications })
		});
	}
</script>

<label>
	<input
		type="checkbox"
		role="switch"
		data-slean="switch"
		bind:checked={notifications}
		{onchange}
	/>
	Email notifications
</label>

Compatibility notes

Nothing beyond the checkbox platform features is required, and those are Baseline widely available. Where a screen reader does not map role="switch" on a checkbox, the control is announced as a checkbox and keeps working. The policy is on Browser support.

Testing

Source