# Card

> An <article> with a header, body and footer, elevated or outlined: a container with no behavior.

Page: https://svelte-lean.vercel.app/primitives/card · Markdown: https://svelte-lean.vercel.app/primitives/card.md · Index: https://svelte-lean.vercel.app/llms.txt

A surface with media, a header, a body and a footer. Use it for self-contained items such as a plan, a person or a page link.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/card.css';
</script>

<article data-slean="card">
	<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. Upgrade to keep them for a year.</div>
	<footer data-slean-part="footer">
		<button type="button" data-slean="button" data-size="small">Upgrade</button>
		<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small">Details</button>
	</footer>
</article>
```

A container with no behavior; the buttons are native.

## Examples

### Stretched link

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/card.css';

	const items = [
		{ href: '/primitives/accordion', title: 'Accordion', text: 'Sections that open and close, with no script.' },
		{ href: '/primitives/dialog', title: 'Dialog', text: 'A modal or non-modal dialog in the top layer.' },
		{ href: '/primitives/tabs', title: 'Tabs', text: 'Panels behind a row of tabs, with arrow keys.' }
	];
</script>

<ul class="cards">
	{#each items as item, i (item.href)}
		<li>
			<article data-slean="card">
				{#if i === 0}
					<div data-slean-part="media"><img src="/demo/card-harbor.svg" alt="" /></div>
				{/if}
				<header data-slean-part="header">
					<!-- One real link, in the title: its ::after covers the card. -->
					<h3 data-slean-part="title">
						<a href={item.href} data-slean-part="link">{item.title}</a>
					</h3>
				</header>
				<div data-slean-part="body">{item.text}</div>
				<footer data-slean-part="footer">
					<!-- A control takes the pointer back from the link: a separate target. -->
					<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Save</button>
				</footer>
			</article>
		</li>
	{/each}
</ul>

<style>
	.cards {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 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 click anywhere follows the title link; Save stays a separate target.

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/card.css';
</script>

<!-- elevation (the default): a shadow; outlined: a border and no shadow. -->
{#each ['elevation', 'outlined'] as variant}
	<article data-slean="card" data-variant={variant}>
		<header data-slean-part="header">
			<h3 data-slean-part="title">Monthly usage</h3>
			<p data-slean-part="description">data-variant="{variant}"</p>
		</header>
		<div data-slean-part="body">
			<p class="figure">48,210 requests</p>
		</div>
	</article>
{/each}

<style>
	.figure {
		margin: 0;
		font-size: 1.75rem;
		font-weight: 600;
	}
</style>
```

elevation (the default) draws --slean-card-elevation; outlined a border and no shadow.

### Form card

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/card.css';
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
</script>

<form data-slean="card" aria-labelledby="invite-title" onsubmit={(event) => event.preventDefault()}>
	<header data-slean-part="header">
		<h3 id="invite-title" data-slean-part="title">Invite a teammate</h3>
		<p data-slean-part="description">They get an email with a sign-in link.</p>
	</header>
	<div data-slean-part="body" class="fields">
		<div data-slean="field">
			<label for="invite-name" data-slean-part="label">Name</label>
			<input id="invite-name" name="name" autocomplete="off" data-slean="input" />
		</div>
		<div data-slean="field">
			<label for="invite-email" data-slean-part="label">Email</label>
			<input id="invite-email" name="email" type="email" autocomplete="off" data-slean="input" />
		</div>
	</div>
	<footer data-slean-part="footer">
		<button data-slean="button">Send invite</button>
		<button type="reset" data-slean="button" data-variant="text" data-color="neutral">Clear</button>
	</footer>
</form>

<style>
	.fields {
		display: grid;
		gap: 1rem;
	}
</style>
```

Composed with Field and Input; the footer holds the submit and reset buttons.

### Profile card

```svelte
<script lang="ts">
	import '@svelte-lean/styles/avatar.css';
	import '@svelte-lean/styles/badge.css';
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/card.css';
</script>

<article data-slean="card" aria-labelledby="member-amara">
	<header data-slean-part="header" class="member">
		<span data-slean="avatar" data-size="large">
			<img src="/demo/avatar-amara.svg" alt="" width="56" height="56" />
			<span data-slean-part="fallback" aria-hidden="true">AO</span>
		</span>
		<div>
			<h3 id="member-amara" data-slean-part="title">Amara Okafor</h3>
			<p data-slean-part="description">amara@harbor.studio</p>
		</div>
		<span data-slean="badge" data-color="primary">Admin</span>
	</header>
	<footer data-slean-part="footer">
		<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small">Message</button>
		<button type="button" data-slean="button" data-variant="outlined" data-color="error" data-size="small">Remove</button>
	</footer>
</article>

<style>
	.member {
		flex-direction: row;
		align-items: center;
		gap: 0.75rem;
	}
	.member > div {
		flex: 1;
	}
</style>
```

Parts of nested primitives keep their own styles.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/card.css';
```

Use `<article>` for self-contained items and a `<div>`, `<section>` or `<form>` 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>`, so a global `img { max-width: 100% }` cannot undo the bleed to the edges. Mark the title link `data-slean-part="link"` only when the whole card should open it.

In a linked card, the links, buttons, inputs, selects, text areas, summaries, elements with `tabindex`, popovers and dialogs stay their own targets, with everything inside them; any other text, image or label opens the link. The linked card takes no pointer events and is its own stacking context: an element with a `z-index` inside it does not rise above the cards after it, so a menu that must is a popover.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<article data-slean="card">` | – | yes | data-variant="elevation" (default) or "outlined". \<div\> or \<section\> when it only groups content. |
| media | `<div> or <figure> around an <img> or <video>` | `media` | no | A child of the card. Bleeds to the edges and takes the corners when it is the first or last child. |
| header | `<header>` | `header` | no | A child of the card. Holds the title and the description. |
| title | `a heading` | `title` | no | At the level the page outline needs. A child of the header (or of a plain wrapper in it, beside an avatar) or of the card. |
| description | `<p>` | `description` | no | Muted, smaller text under the title; placed like the title. |
| body | `<div>` | `body` | no | A child of the card. Takes the free height, so footers in one grid row line up. |
| footer | `<footer>` | `footer` | no | A child of the card. Actions or metadata in a wrapping row. |
| link | `<a href>, a child of the title` | `link` | no | The one link whose hit area covers the card. |

## Keyboard

| 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) |

## Styling

`card.css` draws the surface, the elevation (default) and outlined variants, 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` | `calc(var(--slean-spacing) * 4)` | gap |
| `--slean-space-5` | `calc(var(--slean-spacing) * 5)` | padding, inline-size, margin-inline, margin-block-start, margin-block-end |
| `--slean-border` | `var(--slean-neutral-6)` | border, border-color |
| `--slean-card-radius` | `var(--slean-radius-lg)` | 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-card-elevation` | `var(--slean-elevation-1)` | box-shadow |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | 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` | `calc(var(--slean-spacing) * 2)` | gap |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-offset` | `2px` | outline-offset |
| `--slean-elevation-2` | `0 2px 6px oklch(0% 0 0 / 0.08), 0 1px 2px oklch(0% 0 0 / 0.06)` | box-shadow |
| `--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]`, `[popover]`, `[tabindex]`.

Variant attributes: `data-variant` (`outlined`).

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<article>`, `::after`, `:has()`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<article>`

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

Why this implementation

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 cost: text inside a linked card cannot be selected with the pointer.

### 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 under the content of a card that takes no pointer events, the controls taking them back, the focus ring on the card
- the contract
- documentation

Accessibility

- `<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.

Platform features

| 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 |

Without JavaScript

Fully functional: the stretched link, the focus ring and the controls that stay their own targets are CSS.

Server rendering and hydration

Static HTML.

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

Compatibility

Only `:has()` is newer than the rest (Baseline 2023, widely available since June 2026). Without it the link's area is its own text: the link and the other controls work, a click elsewhere on the card does nothing, and the focus ring stays on the title.

Tests and source

- `apps/playground/tests/primitives/display.spec.ts` Playwright: open state, exclusive groups, stretched links, roles and names, carousel scrolling, axe
- `packages/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 behavior
- `packages/primitives/src/card/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/card.css` the optional stylesheet
