# Feature grid

> A feature section: eyebrow, heading and intro over a 2, 3 or 4 column grid of features, set as cells of one hairline-ruled panel, a filled panel or ruled cards, with rows aligned by subgrid.

- ID: `cmp_feature_grid_01`
- Slug: `feature-grid-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-grid-01?variant=blue
- Preview: https://pagesugar.com/preview/feature-grid-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-28a70725ef2208af4372f66e0881d3881a5a330f0b31fed303be159ff4a637bf` |
| `blue` | Blue accent | no | `sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39` |

## 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-grid-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass features as data, with an optional icon snippet, image, badge and link on each. The grid lays them out and lines their rows up; an empty list renders the introduction without a grid. Presentational only: it does not filter, sort, paginate or fetch features, and it does not load, size or lazy-load images beyond the native loading attribute.

Required props: `features`

```svelte
<script lang="ts">
	import FeatureGrid from '$lib/components/feature-grid-01/FeatureGrid.svelte';
	import type { Feature } from '$lib/components/feature-grid-01/types';

	const features: Feature[] = [
		{ title: 'Boards', description: 'Columns of your own, with an owner and a due date on every card.', icon: boardIcon },
		{ title: 'Timelines', description: 'Drag a bar to move a date; linked cards follow.' },
		{
			title: 'Audit log',
			description: 'See who changed what, and export it as CSV.',
			badge: 'Business',
			link: { label: 'About the audit log', href: '/features/audit-log' }
		}
	];
</script>

{#snippet boardIcon()}
	<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
		<rect x="3.75" y="4.75" width="16.5" height="14.5" rx="2" />
		<path d="M9.25 4.75v14.5M14.75 4.75v14.5" />
	</svg>
{/snippet}

<FeatureGrid
	eyebrow="Features"
	title="One set of cards, three ways to work with them"
	description="Boards, timelines and the audit log all read from the same cards."
	{features}
	columns={3}
	action={{ label: 'See every feature', href: '/features' }}
/>
```

Limitations:

- Rows line up with CSS subgrid (Chrome 117, Safari 16, Firefox 71 and later). Older browsers still show a clean grid; only the cross-card alignment is lost.
- A last card alone on its row spans the row and takes the rail layout, unless it has an image; any other short last row leaves plain panel space after its cards.
- Images are cropped to 16:10 with object-cover. Supply art that survives that crop, and give it width, height and alt text.
- When a feature has both an image and an icon, only the image is shown.
- parts/FeatureCard.svelte is an internal renderer; its positioning, padding, hover and focus styles come from the grid, so render cards through FeatureGrid, even a single one.

## Usage guide

### Feature grid

A section heading over a grid of feature cards. Every card has the same four rows (media, title,
description, link), and the grid lines those rows up across each row of cards with CSS subgrid,
so a long description never pushes one card's link below its neighbours'.

#### Choosing a surface

- `bordered` (default): the cards are cells of one panel ruled by one-pixel hairlines. Suits
  product and home pages, and reads as one object rather than a row of boxes.
- `plain`: no panel; a single rule above each card. Suits dense lists of short features and
  editorial pages.
- `filled`: the same panel of cells, set on the `--feature-grid-surface` fill with no outer
  rule. Suits softer consumer sites: clinics, cafés, courses.

#### Columns

`columns` sets the count from 1024px up. Phones show one column and tablets two, and the grid
never uses more columns than there are features. Choose a feature count that divides by the
column count so the last row is full.

#### Links

Give a feature a `link` and the whole card becomes the target. The link label sits at the foot
of the card with an arrow; hovering anywhere on the card tints it and nudges the arrow. The
card title is appended to the link's accessible name, so several "Learn more" links stay
distinct for screen reader users.

#### The card part

`parts/FeatureCard.svelte` renders one feature as four sibling elements with no wrapper, so the
grid's list item can align them with subgrid. It is an internal renderer: the card's positioning
(which the stretched link needs), padding, image bleed, hover tone and focus ring all come from
`FeatureGrid.svelte`. Edit it to change what a card shows; use the grid to place cards, even a
single one.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `features` | `Feature[]` | yes |  | Features in display order: { title, description, icon?, image?, link?, badge? }. icon is a snippet drawing a 24px SVG in currentColor; image is { src, alt, width, height }; link makes the whole card a link. An empty list renders the introduction and footer link without a grid. |
| `eyebrow` | `string` | no |  | Short label above the title, set small, uppercase and muted. |
| `title` | `string` | no |  | Section heading; it also names the section for assistive technology. |
| `description` | `string` | no |  | One or two sentences under the title, held to a readable measure. |
| `columns` | 2 \| 3 \| 4 | no | `3` | Columns from 1024px up. Phones show one column and tablets two. Never more columns than features. |
| `cardVariant` | 'plain' \| 'bordered' \| 'filled' | no | `'bordered'` | Card surface: bordered sets cards as cells of one hairline-ruled panel, filled sets them as cells of one panel on the surface fill, plain puts one rule above each card. |
| `headingLevel` | 2 \| 3 | no | `2` | Level of the section heading; card titles use the next level down. |
| `action` | `{ label: string; href: string }` | no |  | Optional secondary link under the grid, such as the full feature list. |

## Customization

Change features through the features prop and retone the section through seven --feature-grid-\* CSS variables. The accent colours the icons and the focus rings; everything else is text, hairlines, the control border and the filled panel surface.

- Content: add, remove or reorder features. Keep titles to a few words and descriptions to one to three sentences; the grid keeps rows aligned whatever the length, but very uneven copy leaves space under the short cards.
- Columns: three suits six or nine features, four suits eight short ones, two suits four longer ones or cards with images. Pick a count that divides by the columns so the last row is full.
- Surface: cardVariant="bordered" for a ruled panel, "plain" for an editorial list with a rule above each card, "filled" for one softer tinted panel on consumer sites.
- Icons: pass an icon snippet that draws a 24px inline SVG with stroke="currentColor" and stroke-width 1.5. It is drawn in the accent, without a tile behind it.
- Images: give each feature an image of the same aspect; it runs to the cell edges in bordered and filled panels and is rounded in plain cards. A badge sits on the line above the title, at the end, beside the icon when there is one.
- Links: a feature with link makes the whole card clickable, with the link label at the foot; the title is added to the link's accessible name so repeated labels stay distinct.
- Accent: set --feature-grid-accent for icons and focus rings; links and arrows stay in the ink colour because they are secondary actions. The default is near-black, so the section renders monochrome until you choose a colour.
- Worked retone for a dark page: --feature-grid-ink: #fafafa; --feature-grid-muted: #a1a1aa; --feature-grid-hairline: rgb(255 255 255 / 0.1); --feature-grid-control-border: rgb(255 255 255 / 0.5); --feature-grid-surface: #18181b; --feature-grid-accent: #fafafa; --feature-grid-on-accent: #09090b.
- Layout: the column classes are written out in FeatureGrid.svelte (columnClass); edit them there to change breakpoints. Card order inside a card lives in parts/FeatureCard.svelte.
- A single feature, or a last card alone on its row, spans the row and takes a rail: its mark and title in the first third, the description and link beside them.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--feature-grid-accent` |
| `onAccent` | `--feature-grid-on-accent` |
| `ink` | `--feature-grid-ink` |
| `muted` | `--feature-grid-muted` |
| `hairline` | `--feature-grid-hairline` |
| `controlBorder` | `--feature-grid-control-border` |
| `surface` | `--feature-grid-surface` |

## Accessibility

- The section is labelled by its heading through aria-labelledby; without a title it is an unnamed section.
- Cards are a list (ul with role="list", so Safari keeps list semantics without bullets); card titles are headings one level below the section heading.
- A linked card keeps its heading and text as plain content; only the link is interactive. Its box stretches over the card, and its accessible name is the link label followed by the card title, so repeated labels stay distinct.
- Focus shows as a two-pixel accent ring inside the linked card, and on the footer link around the control; both use :focus-visible.
- Icons are hidden from assistive technology; the title carries the meaning. Images need alt text from the consumer, or an empty alt when the copy already says what they show.
- Headings and ids come from $props.id(), so several grids on one page never share ids.
- The hover tone and arrow nudge on linked cards last 150ms; under prefers-reduced-motion the arrow does not move.

Known limitations:

- Text inside a linked card cannot be selected by dragging, because the link covers the card.
- A link's visible box is its label; the card is the pointer target, but automated target-size checks that read the anchor's own box report it as small.

## License

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

## Source

- Palette: Blue accent (`blue`)
- Entry: `FeatureGrid.svelte`
- Suggested directory: `src/lib/components/feature-grid-01`
- Files: 4
- Artifact digest: `sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39`

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

#### `FeatureGrid.svelte`

Role: entry · 10787 bytes · SHA-256 `fed0ee51947b77c678f496bf6cabe0e1f0b815606f04af5422833d7a5e5eb852`

```svelte
<script lang="ts">
	import FeatureCard from './parts/FeatureCard.svelte';
	import { splitLabel } from './label';
	import type { Feature, FeatureCardVariant, FeatureGridColumns, FeatureLink } from './types';

	interface Props {
		/** Features in display order. An empty list renders the introduction and no grid. */
		features: Feature[];
		/** Short label set small and uppercase above the title. */
		eyebrow?: string;
		/** Section heading; it also names the section. */
		title?: string;
		/** One or two sentences under the title. */
		description?: string;
		/** Columns from 1024 px up. Phones show one column and tablets two. */
		columns?: FeatureGridColumns;
		/** Card surface: cells of one ruled panel, a rule above each card, or a raised fill. */
		cardVariant?: FeatureCardVariant;
		/** Level of the section heading; card titles use the next level down. */
		headingLevel?: 2 | 3;
		/** A secondary link under the grid, such as the full feature list. */
		action?: FeatureLink;
	}

	let {
		features,
		eyebrow,
		title,
		description,
		columns = 3,
		cardVariant = 'bordered',
		headingLevel = 2,
		action
	}: Props = $props();

	const uid = $props.id();
	const sectionHeading = $derived(`h${headingLevel}`);
	const cardHeading = $derived((headingLevel + 1) as 3 | 4);

	/*
	 * Never more columns than cards, so two features fill the row instead of leaving an empty
	 * cell beside them. The classes are written out in full so Tailwind can find them.
	 */
	const count = $derived(Math.min(columns, Math.max(features.length, 1)));
	const columnClass = $derived(
		count === 1
			? 'grid-cols-1'
			: count === 2
				? 'grid-cols-1 sm:grid-cols-2'
				: count === 3
					? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
					: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'
	);

	/*
	 * A last card left alone on its row spans the row and takes the rail layout, so no row ends
	 * with an empty half. A lone feature takes the rail at every width from 640 px.
	 * Image cards are the exception below: a cell-sized image beside an empty two thirds reads worse.
	 */
	const lastClass = $derived.by(() => {
		const n = features.length;
		if (n === 1) return 'rail-sm rail-lg';
		// An image card keeps its column: its media is sized for a cell, not a row.
		if (features[n - 1].image) return '';
		const spanSm = count >= 2 && n % 2 === 1;
		if (count === 2) return spanSm ? 'sm:col-span-2 rail-sm rail-lg' : '';
		const spanLg = n % count === 1;
		return [
			spanSm && 'sm:col-span-2 rail-sm',
			spanLg
				? count === 3
					? 'lg:col-span-3 rail-lg'
					: 'lg:col-span-4 rail-lg'
				: spanSm && 'lg:col-span-1'
		]
			.filter(Boolean)
			.join(' ');
	});

	const actionLabel = $derived(splitLabel(action?.label ?? ''));

	// Bordered and filled cards are cells of one panel: each cell draws its end and bottom rules, and the
	// list runs one pixel past the panel so the outer ones are clipped.
	const panel = $derived(cardVariant !== 'plain');

	/*
	 * Plain cards are spaced by a margin under each card, not a row gap: a subgrid shares its
	 * parent's row gap out across its own rows, which would open space above cards with no media.
	 */
	const listClass = $derived(panel ? '-me-px -mb-px' : 'gap-x-8 -mb-8 sm:-mb-12');
	const itemClass = $derived(
		panel
			? 'border-e border-b border-[var(--_hairline)] p-6 sm:p-8'
			: 'mb-8 border-t border-[var(--_hairline)] pt-6 sm:mb-12 sm:pt-8'
	);
</script>

<section
	class="feature-grid px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
	aria-labelledby={title ? `${uid}-title` : undefined}
>
	<div class="mx-auto max-w-6xl">
		{#if eyebrow || title || description}
			<div class="max-w-2xl text-start">
				{#if eyebrow}
					<p
						class="feature-grid__tracked feature-grid__eyebrow text-xs leading-none font-medium tracking-[0.06em] text-balance text-[var(--_muted)] uppercase"
					>
						{eyebrow}
					</p>
				{/if}
				{#if title}
					<svelte:element
						this={sectionHeading}
						id="{uid}-title"
						class={[
							'feature-grid__tracked text-3xl leading-[1.15] font-semibold tracking-tight text-balance break-words text-[var(--_ink)] sm:text-4xl',
							eyebrow && 'mt-2'
						]}
					>
						{title}
					</svelte:element>
				{/if}
				{#if description}
					<p
						class={[
							'max-w-xl text-base leading-6 text-pretty break-words text-[var(--_muted)] sm:text-lg sm:leading-7',
							(eyebrow || title) && 'mt-4'
						]}
					>
						{description}
					</p>
				{/if}
			</div>
		{/if}

		{#if features.length > 0}
			<div
				class={[
					(eyebrow || title || description) && 'mt-12 sm:mt-16',
					panel && 'overflow-hidden rounded-2xl',
					cardVariant === 'bordered' && 'ring-1 ring-[var(--_hairline)] ring-inset',
					cardVariant === 'filled' && 'bg-[var(--_surface)]'
				]}
			>
				<!-- Four subgrid rows per card (media, title, description, link) keep every row of cards on shared lines. -->
				<ul role="list" class={['grid', columnClass, listClass]}>
					{#each features as feature, index (index)}
						<li
							class={[
								'feature-grid__card relative row-span-4 grid min-w-0 grid-rows-subgrid content-start gap-y-0 text-start',
								cardVariant,
								index === features.length - 1 && lastClass,
								itemClass
							]}
						>
							<FeatureCard {feature} headingLevel={cardHeading} variant={cardVariant} />
						</li>
					{/each}
				</ul>
			</div>
		{/if}

		{#if action}
			<a
				href={action.href}
				class="feature-grid__action mt-12 inline-flex min-h-11 max-w-full items-center rounded-lg px-4 py-2 text-sm leading-5 font-medium break-words text-[var(--_ink)] ring-1 ring-[var(--_control-border)] ring-inset focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] sm:mt-16"
			>
				<span class="min-w-0 [overflow-wrap:anywhere]"
					>{actionLabel.head}<span class={actionLabel.grouped ? 'whitespace-nowrap' : undefined}
						>{actionLabel.tail}<svg
							class="feature-grid__arrow ms-2 inline-block size-4 align-[-0.1875rem] rtl:-scale-x-100"
							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
						></span
					></span
				>
			</a>
		{/if}
	</div>
</section>

<style>
	/* Public tokens: set --feature-grid-* on this section or any ancestor to retone it. */
	.feature-grid {
		--_accent: var(--feature-grid-accent, #1d4ed8);
		--_on-accent: var(--feature-grid-on-accent, #ffffff);
		--_ink: var(--feature-grid-ink, #18181b);
		--_muted: var(--feature-grid-muted, #52525b);
		--_hairline: var(--feature-grid-hairline, rgb(0 0 0 / 0.08));
		--_control-border: var(--feature-grid-control-border, rgb(24 24 27 / 0.5));
		--_surface: var(--feature-grid-surface, #f4f4f5);
	}

	/*
	 * A linked card is one target: the link's box covers the card, the card takes the hover
	 * tone and the focus ring, and the arrow steps along the reading direction.
	 */
	.feature-grid__card,
	.feature-grid__action {
		transition-property: background-color, transform;
		transition-duration: 150ms;
		transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
	}
	.feature-grid :global(.feature-grid__arrow) {
		transition: transform 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	.feature-grid__card:has(:global(.feature-grid__link:hover)) {
		background-color: color-mix(in oklab, var(--_ink) 3%, transparent);
	}
	.feature-grid__card.filled:has(:global(.feature-grid__link:hover)) {
		background-color: color-mix(in oklab, var(--_surface) 94%, var(--_ink));
	}
	/* The arrow's right-to-left flip is a separate scale, so this nudge follows the reading direction. */
	.feature-grid__card:has(:global(.feature-grid__link:hover)) :global(.feature-grid__arrow),
	.feature-grid__action:hover :global(.feature-grid__arrow) {
		transform: translateX(2px);
	}
	/* The ring sits 8px inside the cell with an 8px radius, clear of the panel's rounded corners. */
	.feature-grid__card :global(.feature-grid__link:focus-visible)::after {
		inset: 0.5rem;
		border-radius: 0.5rem;
		outline: 2px solid var(--_accent);
	}
	.feature-grid__action:hover {
		background-color: color-mix(in oklab, var(--_ink) 4%, transparent);
	}
	.feature-grid__action:active {
		transform: translateY(1px);
		transition-duration: 80ms;
	}

	/*
	 * The rail: a card with a whole row to itself sets its mark and title in the first third and
	 * the description and link beside them, starting level with the title.
	 */
	@media (min-width: 40rem) and (max-width: 63.999rem) {
		.feature-grid__card.rail-sm {
			grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
			grid-template-rows: auto auto 1fr;
			column-gap: 2rem;
		}
		.feature-grid__card.rail-sm > :global(:nth-child(1)) {
			grid-area: 1 / 1;
		}
		.feature-grid__card.rail-sm > :global(:nth-child(2)) {
			grid-area: 2 / 1;
		}
		.feature-grid__card.rail-sm > :global(:nth-child(3)) {
			grid-area: 2 / 2;
			margin-top: 0;
		}
		.feature-grid__card.rail-sm > :global(:nth-child(4)) {
			grid-area: 3 / 2;
		}
		.feature-grid__card.rail-sm :global(.feature-grid__marks) {
			justify-content: flex-start;
		}
	}
	@media (min-width: 64rem) {
		.feature-grid__card.rail-lg {
			grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
			grid-template-rows: auto auto 1fr;
			column-gap: 2rem;
		}
		.feature-grid__card.rail-lg > :global(:nth-child(1)) {
			grid-area: 1 / 1;
		}
		.feature-grid__card.rail-lg > :global(:nth-child(2)) {
			grid-area: 2 / 1;
		}
		.feature-grid__card.rail-lg > :global(:nth-child(3)) {
			grid-area: 2 / 2;
			margin-top: 0;
		}
		.feature-grid__card.rail-lg > :global(:nth-child(4)) {
			grid-area: 3 / 2;
		}
		.feature-grid__card.rail-lg :global(.feature-grid__marks) {
			justify-content: flex-start;
		}
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.feature-grid :global(.feature-grid__tracked:dir(rtl)) {
		letter-spacing: 0;
	}
	/* Arabic has no capitals, so the eyebrow keeps its presence through size instead. */
	.feature-grid__eyebrow:dir(rtl) {
		font-size: 0.875rem;
	}
	/* Japanese breaks between phrases, never inside a word; Korean breaks at spaces. */
	.feature-grid:lang(ja) :global(:is(h2, h3, h4, p)) {
		word-break: auto-phrase;
	}
	.feature-grid:lang(ko) :global(:is(h2, h3, h4)) {
		word-break: keep-all;
		overflow-wrap: anywhere;
	}

	@media (prefers-reduced-motion: reduce) {
		.feature-grid :global(.feature-grid__arrow) {
			transition: none;
		}
		.feature-grid__card:has(:global(.feature-grid__link:hover)) :global(.feature-grid__arrow),
		.feature-grid__action:hover :global(.feature-grid__arrow) {
			transform: none;
		}
		.feature-grid__action:active {
			transform: none;
		}
	}
</style>
```

#### `label.ts`

Role: helper · 448 bytes · SHA-256 `3319424b58de700f56636e358e82c1d5468a6691f07235f1b84399db4f553754`

```ts
/**
 * Splits a link label so its last word can be held on one line with the arrow after it. A last
 * word longer than 24 characters is left free to wrap, so a long unbroken token never overflows.
 */
export function splitLabel(label: string): { head: string; tail: string; grouped: boolean } {
	const cut = label.lastIndexOf(' ');
	const tail = label.slice(cut + 1);
	return { head: label.slice(0, cut + 1), tail, grouped: tail.length <= 24 };
}
```

#### `parts/FeatureCard.svelte`

Role: component · 3814 bytes · SHA-256 `54a1893df9c3224f8382bb76d60072ec933eb3bf42d4cf083ffdb85744e3a35e`

```svelte
<!--
	One feature: media, title, description and link, in that order. It renders four sibling rows
	and no wrapper, so the grid's list item can line them up across a row with subgrid. It is an
	internal renderer: positioning, padding, the hover tone and the focus ring come from
	FeatureGrid.svelte.
-->
<script lang="ts">
	import { splitLabel } from '../label';
	import type { Feature, FeatureCardVariant } from '../types';

	interface Props {
		feature: Feature;
		/** Heading level for the card title. */
		headingLevel?: 3 | 4;
		/** Bordered and filled cards run images to their edges; plain cards round them. */
		variant?: FeatureCardVariant;
	}

	let { feature, headingLevel = 3, variant = 'bordered' }: Props = $props();

	const uid = $props.id();
	const heading = $derived(`h${headingLevel}`);
	const bleed = $derived(variant !== 'plain');
	const linkLabel = $derived(splitLabel(feature.link?.label ?? ''));
</script>

<div class="min-w-0">
	{#if feature.image}
		<img
			src={feature.image.src}
			alt={feature.image.alt}
			width={feature.image.width}
			height={feature.image.height}
			loading="lazy"
			decoding="async"
			class={[
				'block aspect-[16/10] max-w-none bg-[var(--_surface)] object-cover',
				bleed
					? 'feature-grid__bleed -ms-6 -mt-6 mb-6 w-[calc(100%+3rem)] sm:-ms-8 sm:-mt-8 sm:mb-8 sm:w-[calc(100%+4rem)]'
					: 'mb-6 w-full rounded-xl'
			]}
		/>
	{/if}
	{#if (feature.icon && !feature.image) || feature.badge}
		<!-- The marks line: the icon at the start and the badge at the end, so titles of any length start on one line. -->
		<div class="feature-grid__marks mb-4 flex min-h-6 items-center justify-between gap-4">
			{#if feature.icon && !feature.image}
				<!-- The icon labels the card at illustration size, in the accent and without a tile behind it. -->
				<span class="flex size-6 shrink-0 text-[var(--_accent)] *:size-6" aria-hidden="true">
					{@render feature.icon()}
				</span>
			{/if}
			{#if feature.badge}
				<p
					class="inline-flex min-h-6 max-w-full items-center rounded-full px-2 text-xs leading-4 font-medium [overflow-wrap:anywhere] text-[var(--_ink)] ring-1 ring-[var(--_hairline)] ring-inset"
				>
					{feature.badge}
				</p>
			{/if}
		</div>
	{/if}
</div>

<svelte:element
	this={heading}
	id="{uid}-title"
	class="min-w-0 text-lg leading-[1.3] font-semibold tracking-[-0.015em] text-balance break-words text-[var(--_ink)] rtl:tracking-normal"
>
	{feature.title}
</svelte:element>

<p
	class="mt-2 max-w-[52ch] min-w-0 text-base leading-6 text-pretty break-words text-[var(--_muted)]"
>
	{feature.description}
</p>

<!-- The link row sits on the cell floor, so wrapped and single-line links in one row end on the same line. -->
<div class="min-w-0 self-end">
	{#if feature.link}
		<!-- The link's box stretches over the whole card, so the card is the target and the link keeps its own name. -->
		<a
			href={feature.link.href}
			id="{uid}-link"
			aria-labelledby="{uid}-link {uid}-title"
			class="feature-grid__link mt-6 inline-flex max-w-full text-sm leading-5 font-medium break-words text-[var(--_ink)] after:absolute after:inset-0 focus-visible:outline-none"
		>
			<!-- The arrow runs inline, held to the last word, so a wrapped label never leaves it alone on a line. -->
			<span class="min-w-0 [overflow-wrap:anywhere]"
				>{linkLabel.head}<span class={linkLabel.grouped ? 'whitespace-nowrap' : undefined}
					>{linkLabel.tail}<svg
						class="feature-grid__arrow ms-2 inline-block size-4 align-[-0.1875rem] rtl:-scale-x-100"
						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
					></span
				></span
			>
		</a>
	{/if}
</div>
```

#### `types.ts`

Role: types · 1251 bytes · SHA-256 `71227202568e8d620ddd2caa6f39816bcfec3c14bcb18bd22ed587a8d563fb81`

```ts
import type { Snippet } from 'svelte';

export type FeatureGridColumns = 2 | 3 | 4;

/**
 * The card surface. `bordered` sets every card as a cell of one hairline-ruled panel, `filled`
 * sets them as cells of one panel on the surface fill, and `plain` puts a single rule above each.
 */
export type FeatureCardVariant = 'plain' | 'bordered' | 'filled';

export interface FeatureImage {
	src: string;
	/** Describe what the image shows, or pass an empty string when the copy already says it. */
	alt: string;
	width: number;
	height: number;
}

export interface FeatureLink {
	/** Visible link text. The feature title is added to its accessible name. */
	label: string;
	href: string;
}

export interface Feature {
	/** Card heading. */
	title: string;
	/** One to three sentences of plain text. */
	description: string;
	/** A 24 px inline SVG drawn in currentColor, set in the accent above the title. */
	icon?: Snippet;
	/** Media across the top of the card. Takes the place of the icon when both are given. */
	image?: FeatureImage;
	/** Makes the whole card a link; the label is shown at the foot of the card. */
	link?: FeatureLink;
	/** A short label on the line above the title, at the end, such as a plan name or "New". */
	badge?: string;
}
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39`
- Entry: `FeatureGrid.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_feature_grid_01/1.0.0/blue/sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_feature_grid_01/1.0.0/blue/sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39/bundle.zip (10246 bytes, sha256 `586ff38c267d40303f661e06e387e0f3f4c0069bf95bce052f0aa3d5c1030dc7`)

Files:

- `FeatureGrid.svelte` (entry, 10787 bytes): https://pagesugar.com/artifacts/cmp_feature_grid_01/1.0.0/blue/sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39/source/FeatureGrid.svelte
- `label.ts` (helper, 448 bytes): https://pagesugar.com/artifacts/cmp_feature_grid_01/1.0.0/blue/sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39/source/label.ts
- `parts/FeatureCard.svelte` (component, 3814 bytes): https://pagesugar.com/artifacts/cmp_feature_grid_01/1.0.0/blue/sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39/source/parts/FeatureCard.svelte
- `types.ts` (types, 1251 bytes): https://pagesugar.com/artifacts/cmp_feature_grid_01/1.0.0/blue/sha256-637d3612fb8531fb9efddf29f72deb5383b67b6c36481b1faa939d2239f1fa39/source/types.ts
