# Feature card

> One capability on a card: an optional icon or framed image, a title with an optional badge, a short description and a detail link pinned to the bottom, so cards in a row line up.

- ID: `cmp_feature_card_01`
- Slug: `feature-card-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: section
- Primary category: `features`
- Detail page: https://pagesugar.com/components/feature-card-01
- Preview: https://pagesugar.com/preview/feature-card-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-eed589d8a23497ff1dbfc501c838461777c5ef04551a0ecf38231677224ea7ac` |
| `blue` | Blue accent | no | `sha256-2c8faac8a125a4d25436d0d697976d4a35a0ab9d774921e26b03594423205ed7` |
| `violet` | Violet accent | no | `sha256-bc82987a29bff38d44d11449eaa82493147e62400d9143861e8abf05b7d07f34` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/feature-card-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Presentational only: renders one card from props and an optional icon snippet. It does not lay out several cards; put it in your own grid (each card stretches to its cell height). No runtime packages beyond Svelte and Tailwind CSS.

Required props: `title`, `description`

```svelte
<script lang="ts">
	import FeatureCard from '$lib/components/feature-card-01/FeatureCard.svelte';
</script>

<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
	<li>
		<FeatureCard
			title="Next-day collection"
			description="Order before 2 pm and collect from the shop from 7 am tomorrow."
			badge="New"
			link={{ label: 'Start an order', href: '/order' }}
		>
			{#snippet icon()}
				<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
					<circle cx="12" cy="12" r="8" />
					<path d="M12 8v4l2.5 2.5" />
				</svg>
			{/snippet}
		</FeatureCard>
	</li>
</ul>
```

Limitations:

- Does not arrange several cards; the grid, its columns and gaps are yours.
- Shows either an image or an icon. When both are passed the image wins and the icon is not rendered.
- The whole card is not clickable; only the link is. A stretched-link card is a source edit.
- Images are cropped to 3:2 with object-cover; pick art whose subject survives the crop, or change aspect-3/2 in the source.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.

## Usage guide

### Feature card

#### Placing it

Copy `FeatureCard.svelte` into `src/lib/components/feature-card-01/`. The card lays out one capability; the grid around it is yours. Put each card in a list item of any grid:

```svelte
<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
	{#each features as feature (feature.title)}
		<li><FeatureCard {...feature} /></li>
	{/each}
</ul>
```

The card is `h-full` and a flex column, so every card in a row takes the row's height and the links sit on one line at the bottom, however long each description runs.

`FeatureCardImage`, `FeatureCardLink` and `FeatureCardVariant` are exported from the component's module script.

#### Content order

| Part        | When it renders                                                                         |
| ----------- | --------------------------------------------------------------------------------------- |
| Image       | `image` is set. Cropped to 3:2, framed 8 px inside a contained card.                    |
| Icon        | `icon` is set and `image` is not. Drawn at 24 px in the ink colour.                     |
| Title       | Always. The heading level comes from `headingLevel` (3 or 4).                           |
| Badge       | `badge` is set. Beside the title, level with its first line; below it in a narrow card. |
| Description | Always.                                                                                 |
| Link        | `link` is set. Pinned to the bottom of the card.                                        |

With neither an image nor an icon, the card starts at the title; no empty media box renders.

#### Variants

- `bordered` (default): white surface, one-pixel hairline, 16 px corners.
- `filled`: a raised zinc fill with no hairline, for cards on a white page with nothing else bordered nearby.
- `plain`: no container or padding. Use it in dense grids that separate items with space or dividers.

#### Link names

A link labelled "Learn more" is meaningless in a screen reader's list of links. When the label does not contain the title, the card appends the title as visually hidden text, so "Learn more" is announced as "Learn more: Board templates". A specific label ("See cake sizes") is still better for everyone.

#### Retoning

Seven variables, set on the card or any ancestor, cover every colour it draws:

| Variable                   | Default             | Draws                                    |
| -------------------------- | ------------------- | ---------------------------------------- |
| `--feature-card-accent`    | `#18181b`           | Link, focus ring, badge fill             |
| `--feature-card-on-accent` | `#ffffff`           | Badge text                               |
| `--feature-card-ink`       | `#18181b`           | Title, icon                              |
| `--feature-card-muted`     | `#52525b`           | Description                              |
| `--feature-card-hairline`  | `rgb(0 0 0 / 0.08)` | Bordered card edge, image frame          |
| `--feature-card-surface`   | `#ffffff`           | Bordered card background, link hover mix |
| `--feature-card-fill`      | `#f4f4f5`           | Filled card background                   |

A dark page:

```svelte
<div
	style="--feature-card-surface: #18181b; --feature-card-fill: #27272a; --feature-card-ink: #fafafa; --feature-card-muted: #a1a1aa; --feature-card-hairline: rgb(255 255 255 / 0.1); --feature-card-accent: #fafafa; --feature-card-on-accent: #18181b;"
>
	<FeatureCard title="Order by 2 pm, collect tomorrow" description="…" />
</div>
```

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `title` | `string` | yes |  | Capability name, set as the card heading. |
| `description` | `string` | yes |  | One to three sentences on what the capability does. |
| `icon` | `Snippet` | no |  | Decorative inline SVG drawn at 24 px in the ink colour above the title. Hidden from assistive technology. Ignored when image is set. |
| `image` | `FeatureCardImage` | no |  | { src, alt, width, height }: media above the text, cropped to 3:2 with object-cover. Use alt: "" when the image only illustrates the text. |
| `link` | `FeatureCardLink` | no |  | { label, href }: detail link pinned to the bottom of the card. If the label does not contain the title, the title is added as visually hidden text. |
| `badge` | `string` | no |  | Short status label beside the title, such as "New", set on the accent. |
| `headingLevel` | 3 \| 4 | no | `3` | Heading level of the title, to fit the page outline. |
| `variant` | `FeatureCardVariant` | no | `'bordered'` | 'plain' \| 'bordered' \| 'filled'. Plain has no container, bordered a hairline on the surface colour, filled a raised fill. |

## Customization

Change content through props, retone the card through seven --feature-card-\* CSS variables, and edit Tailwind classes in the source for padding, radius or the image crop.

- Accent: --feature-card-accent colours the link, the focus ring and the badge fill; --feature-card-on-accent is the badge text. Keep the pair at 4.5:1.
- Text: --feature-card-ink sets the title and the icon; --feature-card-muted the description. Keep muted at 4.5:1 against the surface or fill.
- Surfaces: --feature-card-surface is the bordered card's background, --feature-card-fill the filled card's; --feature-card-hairline draws the bordered edge and the image frame.
- Dark page retone: surface #18181b, fill #27272a, ink #fafafa, muted #a1a1aa, hairline rgb(255 255 255 / 0.1), accent #fafafa, on-accent #18181b (all --feature-card-\*). usage.md has the snippet.
- Icons: pass an inline SVG in the icon snippet with stroke="currentColor"; it is sized to 24 px and takes the ink colour, since it labels rather than acts.
- Links: make the label specific where you can ("See cake sizes"); a generic "Learn more" still gets a unique accessible name from the title.
- Grid: put cards in any grid; each card is h-full and flex-col, so the link sits at the bottom of the row's tallest cell.
- Headings: set headingLevel to 4 when the grid sits under an h3.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--feature-card-accent` |
| `onAccent` | `--feature-card-on-accent` |
| `ink` | `--feature-card-ink` |
| `muted` | `--feature-card-muted` |
| `hairline` | `--feature-card-hairline` |
| `surface` | `--feature-card-surface` |
| `fill` | `--feature-card-fill` |

## Accessibility

- Each card is an article labelled by its title heading; set headingLevel so the heading fits the page outline.
- The icon wrapper is aria-hidden; the icon is decoration beside the title.
- Image alt text comes from image.alt; pass an empty string when the picture only illustrates the text.
- When the link label does not contain the title ("Learn more"), the title is appended as visually hidden text, so every link in a grid has a unique accessible name.
- The link shows a two-pixel accent outline offset by two pixels on :focus-visible only, and is 48 px tall for coarse pointers while its visible gaps stay 24 px.
- Hover darkens the link one step towards the ink colour (so it never loses contrast), underlines the label and nudges the arrow two pixels; pressing deepens it again and moves it down one pixel for 80 ms. Both movements are removed under prefers-reduced-motion. The card itself has no hover because it is not clickable.
- Contrast on white: muted #52525b 7.7:1; ink #18181b 17.7:1; badge white on #18181b 17.7:1, on blue #1d4ed8 6.7:1, on violet #6d28d9 7.1:1. On the filled #f4f4f5 muted text measures 7.1:1.
- Logical properties throughout: the layout, badge and arrow mirror under dir="rtl", and title tracking resets for right-to-left and CJK text; Japanese wraps at phrase boundaries with strict line breaking where the browser supports it.

Known limitations:

- Contrast is computed for the shipped palettes only; re-check any changed token (4.5:1 text, 3:1 focus ring against the surface).
- The hidden title is skipped only when the label already contains the whole title as a phrase; any other label, however specific, is announced with the title appended.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `FeatureCard.svelte`
- Suggested directory: `src/lib/components/feature-card-01`
- Files: 1
- Artifact digest: `sha256-eed589d8a23497ff1dbfc501c838461777c5ef04551a0ecf38231677224ea7ac`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `FeatureCard.svelte`

Role: entry · 7560 bytes · SHA-256 `8b2157bc33659de37b6914365abc4c9e13751ae7d298bdfcc41d16c8549d37a7`

```svelte
<script module lang="ts">
	export interface FeatureCardImage {
		src: string;
		/** Empty string when the image only illustrates the text beside it. */
		alt: string;
		width: number;
		height: number;
	}

	export interface FeatureCardLink {
		label: string;
		href: string;
	}

	export type FeatureCardVariant = 'plain' | 'bordered' | 'filled';
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** Capability name, set as the card heading. */
		title: string;
		/** One to three sentences on what the capability does. */
		description: string;
		/** Decorative glyph above the title, drawn at 24 px in the ink colour. Ignored when image is set. */
		icon?: Snippet;
		/** Media above the text, cropped to 3:2 with object-cover. */
		image?: FeatureCardImage;
		/** Detail link pinned to the bottom of the card. */
		link?: FeatureCardLink;
		/** Short status label beside the title, e.g. "New". */
		badge?: string;
		headingLevel?: 3 | 4;
		variant?: FeatureCardVariant;
	}

	let {
		title,
		description,
		icon,
		image,
		link,
		badge,
		headingLevel = 3,
		variant = 'bordered'
	}: Props = $props();

	const uid = $props.id();
	const heading = $derived(`h${headingLevel}`);
	const contained = $derived(variant !== 'plain');

	/*
	 * A label such as "Learn more" says nothing in a screen reader's list of links, so the title is
	 * added as visually hidden text unless the label already contains it.
	 */
	const fold = (text: string) => text.toLocaleLowerCase().replace(/\s+/g, ' ').trim();
	/* The title counts only as a whole phrase, so a title of "AI" is not found inside "Details". */
	const containsPhrase = (label: string, phrase: string) => {
		const at = phrase ? label.indexOf(phrase) : -1;
		if (at < 0) return false;
		const letter = /[\p{L}\p{N}]/u;
		return !letter.test(label[at - 1] ?? '') && !letter.test(label[at + phrase.length] ?? '');
	};
	const namesFeature = $derived(link ? containsPhrase(fold(link.label), fold(title)) : true);
</script>

<article
	class={[
		'feature-card @container flex h-full min-w-0 flex-col text-start',
		variant === 'bordered' && 'rounded-2xl bg-[var(--_surface)] p-6 ring-1 ring-[var(--_hairline)]',
		variant === 'filled' && 'rounded-2xl bg-[var(--_fill)] p-6'
	]}
	aria-labelledby="{uid}-title"
>
	{#if image}
		<!-- Inside a card the frame sits 8 px from the edge, so its 8 px corners share the card's centre. -->
		<div
			class={[
				'feature-card__frame relative mb-6 overflow-hidden rounded-lg',
				contained && '-mx-4 -mt-4'
			]}
		>
			<img
				src={image.src}
				alt={image.alt}
				width={image.width}
				height={image.height}
				loading="lazy"
				decoding="async"
				class="aspect-3/2 w-full object-cover"
			/>
		</div>
	{:else if icon}
		<div
			class="mb-4 flex size-6 text-[var(--_ink)] [&>svg]:size-6 [&>svg]:shrink-0"
			aria-hidden="true"
		>
			{@render icon()}
		</div>
	{/if}

	<!-- In a narrow card the badge drops below the title, so the title keeps the full column. -->
	<div class="flex min-w-0 flex-col items-start gap-2 @xs:flex-row @xs:justify-between @xs:gap-3">
		<svelte:element
			this={heading}
			id="{uid}-title"
			class="feature-card__tight min-w-0 text-lg/6 font-semibold tracking-[-0.015em] text-balance break-words text-[var(--_ink)]"
		>
			{title}
		</svelte:element>
		{#if badge}
			<!-- The badge box is one title line tall, so it stays level with the first line. -->
			<p class="flex h-6 shrink-0 items-center">
				<span
					class="rounded-md bg-[var(--_accent)] px-2 text-xs/5 font-medium text-[var(--_on-accent)]"
					>{badge}</span
				>
			</p>
		{/if}
	</div>

	<p
		class="feature-card__prose mt-3 max-w-prose text-base/6 text-pretty break-words text-[var(--_muted)]"
	>
		{description}
	</p>

	{#if link}
		<div class="mt-auto pt-6">
			<a
				href={link.href}
				class="feature-card__link inline-flex max-w-full items-center gap-2 rounded-md text-sm/6 font-medium break-words text-[var(--_accent)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]"
			>
				<span class="feature-card__label min-w-0"
					>{link.label}{#if !namesFeature}<span class="sr-only">: {title}</span>{/if}</span
				>
				<svg
					class="feature-card__arrow size-4 shrink-0"
					viewBox="0 0 16 16"
					fill="none"
					aria-hidden="true"
				>
					<path
						d="M3 8h9.5M8.5 4l4 4-4 4"
						stroke="currentColor"
						stroke-width="1.75"
						stroke-linecap="round"
						stroke-linejoin="round"
					/>
				</svg>
			</a>
		</div>
	{/if}
</article>

<style>
	/* Public tokens: set --feature-card-* on the card or any ancestor to retone it. */
	.feature-card {
		--_accent: var(--feature-card-accent, #18181b);
		--_on-accent: var(--feature-card-on-accent, #ffffff);
		--_ink: var(--feature-card-ink, #18181b);
		--_muted: var(--feature-card-muted, #52525b);
		--_hairline: var(--feature-card-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--feature-card-surface, #ffffff);
		--_fill: var(--feature-card-fill, #f4f4f5);
	}

	/* A hairline drawn over the image, so a pale picture still has an edge. */
	.feature-card__frame::after {
		content: '';
		position: absolute;
		inset: 0;
		border-radius: inherit;
		box-shadow: inset 0 0 0 1px var(--_hairline);
		pointer-events: none;
	}

	/*
	 * The link is 48 px tall for coarse pointers, but its negative block margins keep the visible
	 * gap above and below it at 24 px, the same as the card's padding.
	 */
	.feature-card__link {
		min-height: 48px;
		margin-block: -12px;
		transition:
			color 150ms cubic-bezier(0.2, 0, 0, 1),
			transform 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	/*
	 * Hover moves the tone towards the ink, which only ever raises contrast, and draws a thin
	 * underline so the change reads on the neutral palette too, where accent and ink are one colour.
	 */
	.feature-card__link:hover {
		color: color-mix(in oklab, var(--_accent) 78%, var(--_ink));
	}
	.feature-card__link:hover .feature-card__label {
		text-decoration-line: underline;
		text-decoration-thickness: 1px;
		text-underline-offset: 4px;
	}
	/* Press: one step deeper and one pixel down, for 80 ms. */
	.feature-card__link:active {
		color: color-mix(in oklab, var(--_accent) 60%, var(--_ink));
		transform: translateY(1px);
		transition-duration: 80ms;
	}
	.feature-card__arrow {
		transition: transform 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	.feature-card__link:hover .feature-card__arrow {
		transform: translateX(2px);
	}
	.feature-card__arrow:dir(rtl) {
		transform: scaleX(-1);
	}
	.feature-card__link:hover .feature-card__arrow:dir(rtl) {
		transform: scaleX(-1) translateX(2px);
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.feature-card__tight:dir(rtl) {
		letter-spacing: 0;
	}
	/* Chinese, Japanese and Korean have no spaces to balance on; keep words whole where possible. */
	.feature-card__tight:is(:lang(zh), :lang(ja), :lang(ko)) {
		letter-spacing: 0;
		word-break: keep-all;
	}
	/*
	 * Japanese breaks at phrase boundaries where the browser can find them, and never starts a
	 * line with a small kana or a long-vowel mark. keep-all is the fallback for older engines.
	 */
	:is(.feature-card__tight, .feature-card__prose):lang(ja) {
		line-break: strict;
		word-break: auto-phrase;
	}

	@media (prefers-reduced-motion: reduce) {
		.feature-card__link:active {
			transform: none;
		}
		.feature-card__link:hover .feature-card__arrow {
			transform: none;
		}
		.feature-card__link:hover .feature-card__arrow:dir(rtl) {
			transform: scaleX(-1);
		}
	}
</style>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-eed589d8a23497ff1dbfc501c838461777c5ef04551a0ecf38231677224ea7ac`
- Entry: `FeatureCard.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/neutral/sha256-eed589d8a23497ff1dbfc501c838461777c5ef04551a0ecf38231677224ea7ac/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/neutral/sha256-eed589d8a23497ff1dbfc501c838461777c5ef04551a0ecf38231677224ea7ac/bundle.zip (5834 bytes, sha256 `9620813454d86e4029180fc5e895a7b7eb3145c4deba5973bf9aa70303b309df`)

Files:

- `FeatureCard.svelte` (entry, 7560 bytes): https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/neutral/sha256-eed589d8a23497ff1dbfc501c838461777c5ef04551a0ecf38231677224ea7ac/source/FeatureCard.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-2c8faac8a125a4d25436d0d697976d4a35a0ab9d774921e26b03594423205ed7`
- Entry: `FeatureCard.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/blue/sha256-2c8faac8a125a4d25436d0d697976d4a35a0ab9d774921e26b03594423205ed7/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/blue/sha256-2c8faac8a125a4d25436d0d697976d4a35a0ab9d774921e26b03594423205ed7/bundle.zip (5843 bytes, sha256 `a9677b06f90d1d217e6e4058f77ec349b37a0f4f2fdb4f79c6dfa384a06b61c7`)

Files:

- `FeatureCard.svelte` (entry, 7560 bytes): https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/blue/sha256-2c8faac8a125a4d25436d0d697976d4a35a0ab9d774921e26b03594423205ed7/source/FeatureCard.svelte

### Violet accent (`violet`)

- Artifact digest: `sha256-bc82987a29bff38d44d11449eaa82493147e62400d9143861e8abf05b7d07f34`
- Entry: `FeatureCard.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/violet/sha256-bc82987a29bff38d44d11449eaa82493147e62400d9143861e8abf05b7d07f34/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/violet/sha256-bc82987a29bff38d44d11449eaa82493147e62400d9143861e8abf05b7d07f34/bundle.zip (5847 bytes, sha256 `0f97c12d9134a5819993ef345581c0dda48508496010f6444f189a1a8cf9e103`)

Files:

- `FeatureCard.svelte` (entry, 7560 bytes): https://pagesugar.com/artifacts/cmp_feature_card_01/1.0.0/violet/sha256-bc82987a29bff38d44d11449eaa82493147e62400d9143861e8abf05b7d07f34/source/FeatureCard.svelte
