Skip to content
Download ZIP

Neutral palette · 10.0 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_feature_grid_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_feature_grid_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Neutral
Version
1.0.0
Digest
Full digest
sha256-28a70725ef2208af4372f66e0881d3881a5a330f0b31fed303be159ff4a637bf
FeatureGrid.svelte Svelte · 10.5 KB Raw
<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, #18181b);
		--_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>

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.

Suggested location
src/lib/components/feature-grid-01
Required props
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.

Example

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' }}
/>

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.

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 and content inputs#

On this page
NameTypeRequiredDefaultDescription
featuresFeature[]YesNoneFeatures 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.
eyebrowstringNoNoneShort label above the title, set small, uppercase and muted.
titlestringNoNoneSection heading; it also names the section for assistive technology.
descriptionstringNoNoneOne or two sentences under the title, held to a readable measure.
columns2 | 3 | 4No3Columns 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.
headingLevel2 | 3No2Level of the section heading; card titles use the next level down.
action{ label: string; href: string }NoNoneOptional secondary link under the grid, such as the full feature list.

Customization#

On this page

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.

Public CSS variables

VariableToken
--feature-grid-accentaccent
--feature-grid-on-accentonAccent
--feature-grid-inkink
--feature-grid-mutedmuted
--feature-grid-hairlinehairline
--feature-grid-control-bordercontrolBorder
--feature-grid-surfacesurface

Accessibility#

On this page
  • 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.

Release details#

On this page
Integration
  • Presentational
  • Works without client-side JavaScript
  • Server-side rendering supported
Dependencies
No additional runtime packages beyond Svelte and Tailwind CSS
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.