sveltelean Primitives
Versionv0.2.0 GitHub

Example

The trail of this page
Tier 0: the two sources differ only by the stylesheet import. The links go to real pages of this site. The chevrons are generated content with no text; turn on RTL to see them mirror. Everything here works with page JavaScript disabled.

Why this implementation exists

A breadcrumb is links in a list inside a navigation landmark, which the WAI-ARIA Authoring Practices spell out: a named <nav>, an <ol>, and aria-current="page" on the last item. Nothing in it needs a script.

The one part that goes wrong in markup is the separator: a typed “/” or “>” is read aloud between every level. breadcrumb.css draws the separators as bordered ::before boxes with no text, mirrored under RTL, so the markup holds only the links.

The browser owns

  • the navigation landmark and its name
  • the list and the number of levels
  • the links, their focus and Enter
  • the current page state (aria-current)

Svelte Lean owns

  • breadcrumb.css: the row, the separators drawn with borders and mirrored under RTL
  • the current page’s weight, the link hover and focus ring, the coarse-pointer height
  • 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.

stylesheets
import '@svelte-lean/styles/breadcrumb.css';

Name the landmark with aria-label="Breadcrumb" (or aria-labelledby), list the levels in an <ol> directly inside it, and mark the last item with aria-current="page".

Do not type separators into the markup; the stylesheet draws them.

Anatomy

PartElementdata-slean-partRequiredNotes
root<nav aria-label="Breadcrumb" data-slean="breadcrumb">–yesA named navigation landmark.
list<ol> directly in the root–yesOne <li> per level, from the site root to the current page.
current page<a href aria-current="page"> or <span aria-current="page">–yesThe last item.

Runtime profile

Tier 0: the breadcrumb has no behavior module, the Vite plugin maps breadcrumb to no module, and the page ships no Svelte Lean JavaScript for it.

Accessibility contract

  • A named <nav> landmark, told apart from the page’s other navigation by its name.
  • An <ol>: screen readers announce the number of levels.
  • aria-current="page" on the current page, a link or a span.
  • The separators are empty generated boxes: not in the text, not announced.

Keyboard

KeyWhenResult
Tab/Shift+TabanywhereMoves between the links (native)
Enterfocus on a linkFollows it (native)

Platform features

FeatureBaselineOutside the target
<nav>, <ol>, aria-currentWidely availableNot applicable
rotateBaseline 2022A separator is an unturned corner
:dir()Baseline 2023The chevrons point to the right under RTL as well

Without JavaScript

Fully functional: the levels are plain links.

Server rendering

Static markup, rendered with the page from its route.

Before hydration

Nothing is attached; the links work before and after hydration alike.

Styling

breadcrumb.css lays the items out in a wrapping row, draws each separator as a chevron with two borders on li + li::before (turned toward the inline end and mirrored under :dir(rtl)), mutes the ancestors and sets the current page in the text color. Links get the shared focus ring and, on a coarse pointer, the small control height. The rules use child combinators, so a list nested in an item keeps its own styles. The tokens it reads:

TokenDefault (light)Applies to
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-leading1.5line-height
--slean-space-10.25remgap
--slean-space-20.5remgap
--slean-icon-chevron-righturl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-radius-sm0.375remborder-radius
--slean-fgvar(--slean-neutral-12)color
--slean-font-weight-medium500font-weight
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-offset2pxoutline-offset
--slean-control-height-sm2remmin-block-size

State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :focus-visible, :hover, [aria-current="page"], [href].

Controlled integration

The application owns the trail. In SvelteKit it can come from the route: one level per path segment, the last one current.

Breadcrumb.svelte
<script lang="ts">
	import { page } from '$app/state';

	// A trail from the route: one level per path segment, the last one current.
	const trail = $derived.by(() => {
		const segments = page.url.pathname.split('/').filter(Boolean);
		return [
			{ href: '/', label: 'Home' },
			...segments.map((segment, i) => ({
				href: '/' + segments.slice(0, i + 1).join('/'),
				label: segment.replaceAll('-', ' ')
			}))
		];
	});
</script>

<nav aria-label="Breadcrumb" data-slean="breadcrumb">
	<ol>
		{#each trail as level, i (level.href)}
			<li>
				<a href={level.href} aria-current={i === trail.length - 1 ? 'page' : undefined}>
					{level.label}
				</a>
			</li>
		{/each}
	</ol>
</nav>

Compatibility notes

The elements and aria-current are widely available. The chevrons use the rotate property (Baseline 2022) and :dir() (Baseline 2023); without the first a separator is an unturned corner, without the second the chevrons point right under RTL as well.

Examples

Current page as text

The pattern makes the current page a link with aria-current="page". When it should not be a link, a <span aria-current="page"> is styled the same way.

current page as text
<nav aria-label="Breadcrumb" data-slean="breadcrumb">
	<ol>
		<li><a href="/">Home</a></li>
		<li><a href="/table">Table</a></li>
		<li><span aria-current="page">Pagination</span></li>
	</ol>
</nav>

Testing

Source