sveltelean Primitives
Versionv0.2.0 GitHub

Example

Checkout
  1. Cart Completed
  2. Shipping Address and delivery date
  3. Payment Card or invoice
  4. Review
<ol data-slean="steps" aria-label="Checkout">
	<li>
		<span data-slean-part="title">Cart</span>
		<span data-slean-part="complete">Completed</span>
	</li>
	<li aria-current="step">
		<span data-slean-part="title">Shipping</span>
		<span data-slean-part="description">Address and delivery date</span>
	</li>
	<li>
		<span data-slean-part="title">Payment</span>
		<span data-slean-part="description">Card or invoice</span>
	</li>
	<li><span data-slean-part="title">Review</span></li>
</ol>
Tier 0: the two sources differ only by the stylesheet import. The numbers are CSS counters; the check comes from the hidden “Completed” text through :has(). Everything here works with page JavaScript disabled.

Why this implementation exists

A stepper is an ordered list with a current item: the <ol> gives each step its position (“2 of 4”) and aria-current="step" marks where the reader is. Neither needs a script.

The state that markup usually loses is “complete”: a check drawn for sighted readers and nothing for a screen reader. Here the complete state is one element, a text that says “Completed”, hidden visually and read aloud; steps.css draws the check from that same element with :has(), so what is seen and what is heard cannot disagree.

The browser owns

  • the ordered list and each step’s position
  • the current step state (aria-current)
  • the counter behind each number

Svelte Lean owns

  • steps.css: the indicators, the connectors, the current and complete states
  • the horizontal and vertical orientations
  • the contract: one element that both says and shows that a step is complete
  • 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
stylesheets
import '@svelte-lean/styles/steps.css';

Render one <li> per step, aria-current="step" on the current one, and the complete part in every step that is done. Write the complete text in the language of the page.

A step that can be revisited holds a link. When the steps navigate a multi-page form, wrap the list in a named <nav>.

Anatomy

PartElementdata-slean-partRequiredNotes
root<ol data-slean="steps">–yesdata-slean-orientation="horizontal|vertical". Named with aria-label when useful.
step<li>–yesaria-current="step" on the current one.
title<span> or <a href>titlestyles onlyThe step’s name.
description<span>descriptionnoA second, smaller line.
complete<span> with textcompleteyesIn every complete step. Read by assistive technology, hidden visually; its presence draws the check.

Runtime profile

Tier 0: the steps have no behavior module, the Vite plugin maps steps to no module, and the page ships no Svelte Lean JavaScript for them.

Accessibility contract

  • An <ol>: screen readers announce each step’s position.
  • aria-current="step" on the current step.
  • The complete part is text, clipped to one pixel and read after the step’s title.
  • The step number is generated content with empty alternative text, so the position is not read twice.

Keyboard

KeyWhenResult
Tab/Shift+Tablinks inside the stepsMoves between them (native); the steps are not focusable

Platform features

FeatureBaselineOutside the target
aria-current="step", CSS countersWidely availableNot applicable
:has()Baseline 2023A complete step looks like an upcoming one; its text is still read
Alternative text in contentBaseline 2024A screen reader may read the number with the step
rotateBaseline 2022The check is drawn unturned

Without JavaScript

Fully functional: the list and its states are markup.

Server rendering

Static markup: the server renders the current and complete states from the task’s state.

Before hydration

Nothing is attached; the list is the same before and after hydration.

Styling

steps.css numbers the steps with a counter in a ring (li::before), draws the connector to the next step (li::after), fills the current step’s ring with the accent, and draws a check in the soft accent for a step that contains the complete part, from two gradients in the turned ring. data-slean-orientation="vertical" moves the connector below the ring. Under forced colors the rings and connectors use system colors. The tokens it reads:

TokenDefault (light)Applies to
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-space-20.5rempadding-block-start, inset-inline-start, inset-inline-end
--slean-space-41rempadding-inline-end
--slean-border-strongvar(--slean-neutral-8)border
--slean-surfaceoklch(100% 0 0)background-color
--slean-text-xs0.75remfont-size
--slean-font-weight-semibold600font-weight
--slean-bordervar(--slean-neutral-6)background
--slean-font-weight-medium500font-weight
--slean-fgvar(--slean-neutral-12)color
--slean-accentoklch(54% 0.19 258)border-color, background-color, background
--slean-accent-fgoklch(100% 0 0)color
--slean-icon-checkurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask-image
--slean-space-51.25rempadding-block
--slean-space-30.75rempadding-inline
--slean-space-10.25reminset-block

State selectors the stylesheet targets, all from the platform or ARIA: :last-child, [aria-current="step"].

Variant attributes: data-slean-orientation (vertical).

Controlled integration

The application owns the step index; the markup follows it. Every step before the current one gets the complete part.

Checkout.svelte
<script lang="ts">
	// The application owns the step index; the markup follows it.
	let { current }: { current: number } = $props();
	const steps = ['Cart', 'Shipping', 'Payment', 'Review'];
</script>

<ol data-slean="steps" aria-label="Checkout">
	{#each steps as title, i (title)}
		<li aria-current={i === current ? 'step' : undefined}>
			<span data-slean-part="title">{title}</span>
			{#if i < current}<span data-slean-part="complete">Completed</span>{/if}
		</li>
	{/each}
</ol>

Compatibility notes

The list, aria-current and counters are widely available. :has() (Baseline 2023) draws the complete state; without it a complete step looks like an upcoming one and its text is still read. Alternative text for generated content is Baseline 2024; without it a screen reader may read the number with the step. The check is turned with the rotate property (Baseline 2022).

Examples

Vertical

data-slean-orientation="vertical" stacks the steps with the connector below each indicator. It suits long titles, descriptions and narrow columns; the semantics do not change.

  1. Create the workspace harbor.studio Completed
  2. Invite the team Four people joined Completed
  3. Connect a repository Pull requests appear in the review queue
  4. Plan the first sprint
vertical
<ol data-slean="steps" data-slean-orientation="vertical" aria-label="Onboarding">
	<li>
		<span data-slean-part="title">Create the workspace</span>
		<span data-slean-part="description">harbor.studio</span>
		<span data-slean-part="complete">Completed</span>
	</li>
	<li>
		<span data-slean-part="title">Invite the team</span>
		<span data-slean-part="description">Four people joined</span>
		<span data-slean-part="complete">Completed</span>
	</li>
	<li aria-current="step">
		<span data-slean-part="title">Connect a repository</span>
		<span data-slean-part="description">Pull requests appear in the review queue</span>
	</li>
	<li><span data-slean-part="title">Plan the first sprint</span></li>
</ol>

Testing

Source