Disclosure
Tier 0, details and summary
Primitives /Layout and display
An <article> with media, a header, a body and a footer: a container with no behavior. The package adds the surface, two variants, the part layout and a stretched-link pattern for cards that open one page. Tier 0.
fixtures/ results.json (native-only) · method<article>, ::after, :has()<article>Tier 0, details and summary
Team plan, renews in October
<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><script lang="ts">
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/card.css';
</script>
<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>
<style>
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
/* Each list item stretches, so the cards in a row share one height. */
.cards > li {
display: grid;
}
</style>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 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/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@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.
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <article data-slean="card"> | – | yes | data-variant="outline" (default) or "filled". <div> or <section> when it only groups content. |
| media | <div> or <figure> around an <img> or <video> | media | no | Bleeds to the edges and takes the corners when it is the first or last child. |
| header | <header> | header | no | Holds the title and the description. |
| title | a heading | title | no | At the level the page outline needs. |
| description | <p> | description | no | Muted, smaller text under the title. |
| body | <div> | body | no | Takes the free height, so footers in one grid row line up. |
| footer | <footer> | footer | no | Actions or metadata in a wrapping row. |
| link | <a href> inside the title | link | no | The one link whose hit area covers the card. |
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.
<article> is exposed as an article; name it with aria-labelledby on the title when a page lists many cards.:has() is supported the focus ring of the link is drawn around the card, otherwise around the title.alt=""); an image that carries information gets its own alt text.| Key | When | Result |
|---|---|---|
| Tab/Shift+Tab | anywhere | Moves to the links and buttons inside; the card is not focusable (native) |
| Enter | focus on the stretched link | Follows the link (native) |
| Feature | Baseline | Outside the target |
|---|---|---|
<article>, headings, links | Widely available | Not applicable within the support policy |
:has() | Baseline 2023, widely available since June 2026 | The stretched link covers the other controls and keeps its own focus ring |
Fully functional: the stretched link, the focus ring and the lifted buttons are CSS.
Static HTML.
Nothing is attached; the card behaves the same before and after hydration.
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:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-4 | 1rem | gap |
--slean-space-5 | 1.25rem | padding, inline-size, margin-inline, margin-block-start, margin-block-end |
--slean-border | var(--slean-neutral-6) | border |
--slean-radius-lg | 0.875rem | border-radius, border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-muted | var(--slean-neutral-3) | background |
--slean-space-1 | 0.25rem | gap |
--slean-text-lg | 1.125rem | font-size |
--slean-font-weight-semibold | 600 | font-weight |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-space-2 | 0.5rem | gap |
--slean-focus-ring | var(--slean-focus-ring-width) solid var(--slean-focus-ring-color) | outline |
--slean-focus-ring-offset | 2px | outline-offset |
--slean-border-strong | var(--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).
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.
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.
<!-- 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>apps/playground/tests/primitives/display.spec.ts Playwright: open state, exclusive groups, stretched links, roles and names, carousel scrolling, axepackages/styles/tests static checks of the stylesheet (layers, tokens, specificity, dark parity)packages/primitives/src/card/contract.md the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/card/contract.ts typed constants: name, tier, base, parts, options, eventspackages/styles/css/card.css the optional stylesheet