sveltelean Primitives
Versionv0.2.0 GitHub

Example

Text alignment, single and required
<div
	data-slean="toggle-group"
	role="group"
	aria-label="Text alignment"
	data-slean-type="single"
	data-slean-required
>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="left"
		aria-pressed="true"
		tabindex="0"
	>
		Left
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="center"
		aria-pressed="false"
		tabindex="-1"
	>
		Center
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="right"
		aria-pressed="false"
		tabindex="-1"
	>
		Right
	</button>
</div>
Arrow keys move focus and the tab stop without pressing; Space or Enter presses. In single mode pressing an item releases the others, and data-slean-required keeps one pressed.

Why this implementation exists

A row of toggle buttons that act together needs more than each button’s own state: one tab stop for the row, arrow keys between the items, and in single mode the rule that pressing one releases the others. The platform has none of this for buttons.

The pressed states stay in aria-pressed, which is what screen readers announce, and the tab stop in tabindex. The group keeps no copy of its value: toggleGroupValue() reads the pressed items, so the attributes and the value cannot disagree.

The browser owns

  • the buttons: focus, Enter and Space activation, the accessible names
  • announcing the group name and each pressed state (screen readers)
  • the click and keydown events routed to the behavior

Svelte Lean owns

  • pressing and releasing items, the single rule and data-slean-required
  • the roving tabindex, arrow keys, Home and End, RTL
  • leaving focus movement to a surrounding toolbar
  • the cancelable slean:change with the value list; toggleGroupValue() and setToggleGroupValue()
  • development validation, toggle-group.css

Usage

Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the registration is injected for every static data-slean="toggle-group"; without it, import the register module once.

npm install @svelte-lean/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/toggle-group/register';
stylesheets
import '@svelte-lean/styles/toggle-group.css';

Render every item with aria-pressed and a unique data-slean-value, and the roving tabindex: 0 on the first pressed item (else the first item), -1 on the others. Name the group with aria-label or aria-labelledby.

Set data-slean-type="single" for an exclusive choice and add data-slean-required when one item must stay pressed. Listen to slean:change: detail.value and detail.previous are lists of values in document order.

For a single choice that is a form value, use Segmented or a Radio group: they are native radios with a form value and work without JavaScript.

alignment.svelte
<script lang="ts">
	import {
		setToggleGroupValue,
		toggleGroupValue,
		type ToggleGroupChangeDetail
	} from '@svelte-lean/primitives/toggle-group';

	let group: HTMLElement;

	$effect(() => {
		// slean:change is dispatched from the root before aria-pressed is written; value and
		// previous are lists of data-slean-value in document order. preventDefault() keeps them.
		const onchange = (event: Event) => {
			const { value, previous } = (event as CustomEvent<ToggleGroupChangeDetail>).detail;
			console.log(previous, '->', value);
		};
		group.addEventListener('slean:change', onchange);
		return () => group.removeEventListener('slean:change', onchange);
	});

	// The value is read from aria-pressed each time; the root keeps no copy.
	const current = () => toggleGroupValue(group);
	// Writes through the same cancelable event; false when a listener cancelled it.
	const reset = () => setToggleGroupValue(group, ['left']);
</script>

<div data-slean="toggle-group" role="group" aria-label="Text alignment" bind:this={group}>…</div>

Anatomy

PartElementdata-slean-partRequiredNotes
root<div data-slean="toggle-group" role="group" aria-label="…">–yesOptions: data-slean-type (multiple, single), -required, -orientation (horizontal, vertical), -loop. The value is read from the items; the root holds no copy.
item<button type="button" aria-pressed="false" data-slean-value="…">itemyesUnique data-slean-value. tabindex="0" on one item, "-1" on the others, except inside a toolbar.

Runtime profile

The group registers one click and one keydown handler with the shared router. A thousand groups keep one listener per type (tests/toggle-group.test.ts). Its bytes, in the runtime block, are the production registration with the core kernel.

Accessibility contract

  • role="group" with a name, so the group is announced when focus enters it; each item is a native <button> with aria-pressed.
  • One item is in the tab order; the arrow keys, Home and End move it. Enter and Space press or release the focused item through the native button activation.
  • Item names do not change with the state. aria-disabled="true" keeps an item focusable and skipped by the arrows; disabled removes it from focus.
  • The single rule does not turn the items into radios: they stay toggle buttons, each announced as pressed or not pressed.

Keyboard

KeyWhenResult
Tab/Shift+Tabentering or leavingEnters on the tabindex="0" item, then leaves the group (native)
Enter/Spacefocus on an itemPresses or releases it (native activation)
ArrowRight/ArrowLefthorizontalNext or previous enabled item, without pressing; swapped under dir="rtl"
ArrowDown/ArrowUpverticalNext or previous enabled item, without pressing
Home/Endfocus on an itemFirst or last enabled item

Platform features

FeatureBaselineOutside the target
<button> activationWidely availableNot applicable
aria-pressed, role="group"ARIA 1.2, announced by screen readersNot applicable
:dir()Baseline 2023dir="auto" resolves to left-to-right; explicit dir attributes work

Without JavaScript

The authored states are shown and announced and the authored tab stop is kept, but pressing changes nothing. A choice that must be made without JavaScript is a Radio group or Segmented control (native radios) or a set of checkboxes.

Server rendering

Render aria-pressed and the roving tabindex on the server. No ids are generated, and the register module is safe to import on the server.

Before hydration

Before the behavior loads, a click does nothing and the arrow keys scroll. The registration attaches no per-group state, so the first interaction after hydration works on the server's markup directly.

Styling

toggle-group.css draws one bordered track around the items, fills the pressed items with the accent's soft color and an accent border, stacks the items in the vertical orientation and uses the system highlight colors in forced colors. The tokens it reads:

TokenDefault (light)Applies to
--slean-space-10.25rempadding, min-block-size, border-radius
--slean-bordervar(--slean-neutral-6)border
--slean-radius-md0.625remborder-radius
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-space-20.5remgap, padding-inline
--slean-control-height-md2.25remmin-block-size
--slean-space-30.75rempadding-inline
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-font-weight-medium500font-weight
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-control-height-sm2remmin-block-size
--slean-control-height-lg2.75remmin-block-size
--slean-space-41rempadding-inline
--slean-text-md1remfont-size
--slean-mutedvar(--slean-neutral-3)background
--slean-accentoklch(54% 0.19 258)border-color
--slean-accent-softoklch(95% 0.03 258)background
--slean-accent-soft-fgoklch(42% 0.17 258)color

State selectors the stylesheet targets, all from the platform or ARIA: :disabled, :hover, [aria-disabled="true"], [aria-pressed="true"].

Variant attributes: data-slean-orientation (vertical); data-size (sm, lg).

Compatibility notes

Buttons, aria-pressed and role="group" work in every browser and screen reader in use. dir="auto" needs :dir() (Baseline 2023) to resolve; explicit dir attributes work everywhere. In Safari and in Firefox on macOS a click does not focus a button; the group focuses the clicked item itself.

Examples

Multiple

Without data-slean-type every item is pressed and released on its own, and slean:change reports the whole list. The line under the group reads the event.

No change yet.
multiple
<div data-slean="toggle-group" role="group" aria-label="Text style">
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="bold"
		aria-pressed="true"
		tabindex="0"
	>
		Bold
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="italic"
		aria-pressed="false"
		tabindex="-1"
	>
		Italic
	</button>
	<button
		type="button"
		data-slean-part="item"
		data-slean-value="underline"
		aria-pressed="true"
		tabindex="-1"
	>
		Underline
	</button>
</div>

Vertical

data-slean-orientation="vertical" stacks the items and moves between them with ArrowDown and ArrowUp. role="group" has no aria-orientation, so the orientation lives in the layout and the keys.

options
<div
	data-slean="toggle-group"
	role="group"
	aria-label="View"
	data-slean-type="single"
	data-slean-required
	data-slean-orientation="vertical"
	data-slean-loop="false"
	dir="rtl"
>

In a toolbar

Inside a Toolbar the group handles no arrow key and writes no tabindex: its items become toolbar controls, so the toolbar stays one tab stop and the arrow keys continue past the group's first and last items. Pressing is unchanged.

Testing

Source