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.
cmp_feature_grid_01 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.
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
This component needs all 4 files. Download the ZIP
<!--
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>
Usage#
On this pagePass 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
<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-surfacefill 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 and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
features | Feature[] | Yes | None | 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 | None | Short label above the title, set small, uppercase and muted. |
title | string | No | None | Section heading; it also names the section for assistive technology. |
description | string | No | None | 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 | None | Optional secondary link under the grid, such as the full feature list. |
Customization#
On this pageChange 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-accentfor 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
| Variable | Token |
|---|---|
--feature-grid-accent | accent |
--feature-grid-on-accent | onAccent |
--feature-grid-ink | ink |
--feature-grid-muted | muted |
--feature-grid-hairline | hairline |
--feature-grid-control-border | controlBorder |
--feature-grid-surface | surface |
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.