sveltelean Primitives
Versionv0.2.0 GitHub

Example

A linked card and a filled card
  • Disclosure

    Tier 0, details and summary

    A region that opens and closes, with no script.
  • Storage

    Team plan, renews in October

    Exports older than thirty days are removed.
<ul class="cards">
	<li>
		<article data-slean="card">
			<div data-slean-part="media"><img src="/demo/card-harbor.svg" alt="" /></div>
			<header data-slean-part="header">
				<h3 data-slean-part="title">
					<a href="/primitives/disclosure" data-slean-part="link">Disclosure</a>
				</h3>
				<p data-slean-part="description">Tier 0, details and summary</p>
			</header>
			<div data-slean-part="body">A region that opens and closes, with no script.</div>
			<footer data-slean-part="footer">
				<button type="button" data-slean="button" data-variant="outline" data-size="sm">Save</button>
			</footer>
		</article>
	</li>
	<li>
		<article data-slean="card" data-variant="filled">
			<header data-slean-part="header">
				<h3 data-slean-part="title">Storage</h3>
				<p data-slean-part="description">Team plan, renews in October</p>
			</header>
			<div data-slean-part="body">Exports older than thirty days are removed.</div>
			<footer data-slean-part="footer">
				<button type="button" data-slean="button" data-size="sm">Upgrade</button>
				<button type="button" data-slean="button" data-variant="ghost" data-size="sm">Details</button>
			</footer>
		</article>
	</li>
</ul>
Tier 0: the cards need no script. A click anywhere on the first card follows its title link; Save and the buttons of the second card stay separate targets. The grid is the application's CSS, shown in the Svelte source.

Why this implementation exists

A card is layout: a surface, a heading, some text and actions. <article> gives it meaning when the item stands on its own, the heading gives it a place in the outline, and links and buttons inside it are native. Nothing about a card needs a script or a component.

The one pattern that tempts a script is the clickable card. Wrapping the card in <a> makes the whole text the link name and cannot hold a button; a click handler on the card is invisible to the keyboard. The stretched link keeps one real <a> in the title and enlarges its hit area with a pseudo-element, so the browser still owns focus, Enter, the name and the navigation.

The browser owns

  • the article element and the heading outline
  • the one link: focus, Enter, the accessible name from the title and the navigation
  • the buttons and links inside the card

Svelte Lean owns

  • card.css: the surface, the two variants, the media bleed, the part layout
  • the stretched link: its ::after over the card, the other controls lifted above it, the focus ring on the card
  • 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
stylesheets
import '@svelte-lean/styles/card.css';

Use <article> for self-contained items and a <div> or <section> for plain groups. Put the title at the heading level the page needs. In a list of cards, put each card in an <li>.

Wrap media in data-slean-part="media" rather than marking the <img>: a global img { max-width: 100% } in the application would otherwise undo the bleed to the edges. Mark the title link data-slean-part="link" only when the whole card should open it.

Anatomy

PartElementdata-slean-partRequiredNotes
root<article data-slean="card">–yesdata-variant="outline" (default) or "filled". <div> or <section> when it only groups content.
media<div> or <figure> around an <img> or <video>medianoBleeds to the edges and takes the corners when it is the first or last child.
header<header>headernoHolds the title and the description.
titlea headingtitlenoAt the level the page outline needs.
description<p>descriptionnoMuted, smaller text under the title.
body<div>bodynoTakes the free height, so footers in one grid row line up.
footer<footer>footernoActions or metadata in a wrapping row.
link<a href> inside the titlelinknoThe one link whose hit area covers the card.

Runtime profile

Tier 0: the card has no behavior module, the Vite plugin maps card to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.

Accessibility contract

  • <article> is exposed as an article; name it with aria-labelledby on the title when a page lists many cards.
  • The stretched link is one tab stop named by the title; the other controls keep their own names and tab stops.
  • The card itself is never focusable. Where :has() is supported the focus ring of the link is drawn around the card, otherwise around the title.
  • Media that repeats the title is decorative (alt=""); an image that carries information gets its own alt text.

Keyboard

KeyWhenResult
Tab/Shift+TabanywhereMoves to the links and buttons inside; the card is not focusable (native)
Enterfocus on the stretched linkFollows the link (native)

Platform features

FeatureBaselineOutside the target
<article>, headings, linksWidely availableNot applicable within the support policy
:has()Baseline 2023, widely available since June 2026The stretched link covers the other controls and keeps its own focus ring

Without JavaScript

Fully functional: the stretched link, the focus ring and the lifted buttons are CSS.

Server rendering

Static HTML.

Before hydration

Nothing is attached; the card behaves the same before and after hydration.

Styling

card.css draws the surface and border, the filled variant, the media bleed with the card's corners, the header, title, description, body and footer layout, and the stretched link with the focus ring on the card. The tokens it reads:

TokenDefault (light)Applies to
--slean-space-41remgap
--slean-space-51.25rempadding, inline-size, margin-inline, margin-block-start, margin-block-end
--slean-bordervar(--slean-neutral-6)border
--slean-radius-lg0.875remborder-radius, border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-mutedvar(--slean-neutral-3)background
--slean-space-10.25remgap
--slean-text-lg1.125remfont-size
--slean-font-weight-semibold600font-weight
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-space-20.5remgap
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-offset2pxoutline-offset
--slean-border-strongvar(--slean-neutral-8)border-color

State selectors the stylesheet targets, all from the platform or ARIA: :first-child, :focus-visible, :hover, :last-child, [href], [tabindex].

Variant attributes: data-variant (filled).

Compatibility notes

Only :has() is newer than the rest (Baseline 2023, widely available since June 2026). Without it the stretched link also covers the other buttons of a linked card and keeps its focus ring on the title.

Examples

A card that opens one page has one link, in its title. The stylesheet stretches the link's ::after over the card, lifts every other link and button above it, and moves the focus ring from the title to the card. The link's accessible name stays the title, and the Save button in the first example above remains a separate target. The cost: text inside the card cannot be selected with the pointer.

stretched-link.html
<!-- One real link, inside the title. Its ::after covers the card; the Save button is
     lifted above it by the stylesheet, so it stays a separate target. -->
<article data-slean="card">
	<header data-slean-part="header">
		<h3 data-slean-part="title">
			<a href="/stories/harbor-lights" data-slean-part="link">Harbor lights</a>
		</h3>
	</header>
	<footer data-slean-part="footer">
		<button type="button" data-slean="button">Save</button>
	</footer>
</article>

<!-- Not this: the link's name becomes the whole card, and a link may not contain a button. -->
<a href="/stories/harbor-lights"><article>…<button>Save</button></article></a>

Testing

Source