Component library
Back to catalog

Icon feature grid

A short editorial lead-in above a dense grid of icon-topped feature cards that trade colours with their icon tile on hover.

sectionfeaturesgridcardsiconshover

Open preview in a new tab

Use this component

Three ways to get Icon feature grid into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Icon feature grid from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/icon-feature-grid/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Icon feature grid"
Library page: https://astro.baysixmedia.com/components/icon-feature-grid/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/IconFeatureGrid.astro =====
---
/**
 * IconFeatureGrid — a short editorial lead-in above a dense grid of
 * icon-topped feature cards that trade colours with their icon tile under
 * the pointer.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts, no images, no network requests of any kind: drop it into any
 * Astro project and render it.
 *
 * Two things carry the design. The first is the gap: the icon tile sits at the
 * top of the card and the title starts 48px below it, so every card has a band
 * of empty colour in the middle that the tile appears to float in. The second
 * is the hover trade — the card is tinted and the tile is white at rest, and on
 * hover they swap, so the tile reads as a hole punched through the card rather
 * than a badge sitting on it. The tile tips and lifts at the same moment.
 *
 * Measured behaviour (reproduced from a 1440px reference render):
 *   shell        1024px max width, 48px / 16px / 16px padding, dashed rails
 *   columns      1 -> 2 at >=768px -> 4 at >=1024px (never 3)
 *   grid         8px gap, 32px above, equal-height rows
 *   card         16px padding, 12px radius, tinted ground
 *   tile         52px square, 8px radius, 20px glyph, 2px strokes
 *   rhythm       tile -> 48px -> 14px title -> 8px -> 14px body
 *   hover        card and tile swap grounds; tile rotates -12deg, rises 8px
 *   timing       300ms ease on every hover property, no delay, no stagger
 *   shadow       0 25px 50px -12px rgba(0, 0, 0, 0.25) on the hovered card
 *
 * The section is otherwise static: no scripts, no scroll effects, no entrance
 * animation, nothing that runs on a timer.
 *
 * Several instances can share a page. The only id the component mints is the
 * one joining the section to its heading, and it is derived from an instance
 * uid rather than written into the markup as a constant.
 *
 * Every element here is written by this file. Nothing a caller passes is
 * rendered as markup: a custom glyph arrives as SVG path `d` strings, which
 * become attributes of paths the component writes, so item data from a CMS or
 * an API cannot introduce an element, a handler or a request.
 */

/** A glyph drawn by this component, named for its shape rather than a meaning. */
export type FeatureIcon =
	| "orbit"
	| "pulse"
	| "layers"
	| "exchange"
	| "target"
	| "brackets"
	| "frame"
	| "gauge"
	| "spark";

export interface FeatureItem {
	/** Card title. The only required field. */
	title: string;
	/** Supporting paragraph under the title. Optional. */
	body?: string;
	/** Which built-in glyph to draw. Unknown or missing falls back to "spark". */
	icon?: FeatureIcon;
	/**
	 * SVG path `d` values for a glyph this component does not ship — one
	 * string per `<path>`, drawn on the same 24x24 grid as the built-ins.
	 *
	 * Path data only, never markup: a complete `<svg>` string, an `<image>`,
	 * an event attribute or anything else is not parsed, not unwrapped and not
	 * rendered. Each string becomes the `d` attribute of a `<path>` the
	 * component writes itself, so a value that arrives from a CMS or an API
	 * can draw a wrong shape but cannot introduce an element or a handler.
	 *
	 * Used only when `icon` names no built-in. Empty, blank or absent falls
	 * back to "spark".
	 */
	iconPaths?: string[];
}

interface Props {
	/** Section heading. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	intro?: string;
	/** Heading level, for pages where h2 is the wrong rank. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The cards. Any count works; the grid fills rows left to right. */
	items?: FeatureItem[];
	/** Columns from 1024px up. Below that it is always 2, then 1 under 768px. */
	columns?: 2 | 3 | 4;
	/** Page ground the section paints behind itself. */
	surface?: string;
	/** Card ground at rest. Becomes the tile's ground on hover. */
	cardSurface?: string;
	/** Tile ground at rest. Becomes the card's ground on hover. */
	tileSurface?: string;
	/** Glyph colour. */
	accent?: string;
	/** Title colour. The muted body tone is derived from it. */
	ink?: string;
	/**
	 * Dashed rails down the shell's edges and across its top, which line the
	 * section up with the ones above and below it. Off for a section that
	 * stands alone.
	 */
	rules?: boolean;
	/** Shell max width. The reference sits at 64rem. */
	maxWidth?: string;
	/** Body font stack. */
	font?: string;
	/** Heading font stack. Serif in the reference, against a sans body. */
	headingFont?: string;
	class?: string;
	id?: string;
}

/* Annotated rather than inferred. Dropping the annotation checks clean today
   -- measured, not assumed -- but during this component's development the
   checker's `Props`-to-`Astro.props` wiring proved sensitive to where things
   sit in the frontmatter, and it fails by silently typing every prop `any`
   rather than by complaining. One annotation is cheaper than that. */
const {
	heading = "",
	intro = "",
	headingLevel = "h2",
	items = [],
	columns = 4,
	surface = "#ffffff",
	cardSurface = "#f7f5f0",
	tileSurface = "#ffffff",
	accent = "#e06040",
	ink = "#171717",
	rules = true,
	maxWidth = "64rem",
	font = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
	headingFont = "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif",
	class: className,
	id,
}: Props = Astro.props;

const Heading = headingLevel;

/* No demo content: the cards and the lead-in are props. A card with no title
   is dropped; with no cards left nothing renders, and a heading alone never
   makes the section render. */
const cards = (items ?? []).filter((item) => item?.title);

/* Random rather than sequential: a module-level counter is shared by every
   render in the same server process, which is fine for one page and wrong the
   moment two pages are built in parallel and compared. */
const uid = id ?? `ifg-${Math.random().toString(36).slice(2, 9)}`;
const headingId = `${uid}-heading`;

/*
 * The glyphs. Drawn here as plain path data on a 24x24 grid with 2px strokes
 * and round joins — abstract shapes, not a traced icon set, so the component
 * carries no font and no sprite file and nothing to download.
 */
const glyphs: Record<FeatureIcon, string[]> = {
	orbit: ["M12 3.6a8.4 8.4 0 1 0 0 16.8 8.4 8.4 0 0 0 0-16.8Z", "M17.2 5.6a2.3 2.3 0 1 0 0 4.6 2.3 2.3 0 0 0 0-4.6Z"],
	pulse: ["M2.5 14h3.8l2.6-7.5L13 18l2.3-4h6.2"],
	layers: ["M12 3.2 3.4 7.4 12 11.6l8.6-4.2-8.6-4.2Z", "M3.4 12.2 12 16.4l8.6-4.2", "M3.4 16.8 12 21l8.6-4.2"],
	exchange: ["M7.5 20.5V3.5", "M3.8 7.2 7.5 3.5l3.7 3.7", "M16.5 3.5v17", "M20.2 16.8l-3.7 3.7-3.7-3.7"],
	target: ["M12 3.6a8.4 8.4 0 1 0 0 16.8 8.4 8.4 0 0 0 0-16.8Z", "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z", "M12 1.5v3.2M12 19.3v3.2M1.5 12h3.2M19.3 12h3.2"],
	brackets: ["M8.8 5.6 3.4 12l5.4 6.4", "M15.2 5.6 20.6 12l-5.4 6.4", "M13.4 3.8 10.6 20.2"],
	frame: ["M4 4.2h16a.8.8 0 0 1 .8.8v14a.8.8 0 0 1-.8.8H4a.8.8 0 0 1-.8-.8V5a.8.8 0 0 1 .8-.8Z", "M3.2 9.4h17.6", "M9.4 9.4v10.4"],
	gauge: ["M3.4 17.4a8.6 8.6 0 1 1 17.2 0", "M12 17.4l4.4-5.6", "M6.6 13.2h.01M12 8.6h.01M17.4 13.2h.01"],
	spark: ["M12 2.8v5.4M12 15.8v5.4M2.8 12h5.4M15.8 12h5.4", "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z"],
};

/*
 * Which paths a card draws. A built-in name wins; failing that, a caller's own
 * path data; failing that, the fallback glyph — an unknown name is a typo, not
 * a reason to leave a hole in the card.
 *
 * `Array.isArray` rather than a plain lookup, because an `icon` value that
 * arrived as data rather than as a literal could name something inherited from
 * Object.prototype, and that must read as "not a built-in" rather than as a
 * function to map over.
 */
const pathsFor = (item: FeatureItem): string[] => {
	const named = item.icon ? glyphs[item.icon] : undefined;
	if (Array.isArray(named)) return named;

	/* Blank entries are dropped rather than emitted as empty paths, so a list
	   of nothing but whitespace falls back instead of drawing an empty tile. */
	const custom = (item.iconPaths ?? []).filter(
		(d): d is string => typeof d === "string" && d.trim() !== "",
	);
	return custom.length > 0 ? custom : glyphs.spark;
};

const styleVars = [
	`--ifg-surface:${surface}`,
	`--ifg-card:${cardSurface}`,
	`--ifg-tile:${tileSurface}`,
	`--ifg-accent:${accent}`,
	`--ifg-ink:${ink}`,
	`--ifg-maxw:${maxWidth}`,
	`--ifg-font:${font}`,
	`--ifg-heading-font:${headingFont}`,
	`--ifg-cols:${columns}`,
].join(";");
---

{cards.length > 0 && (
<section
	class:list={["feature-grid", className]}
	id={id}
	aria-labelledby={heading ? headingId : undefined}
	data-rules={rules ? "" : undefined}
	style={styleVars}
>
	<div class="shell">
		{
			(heading || intro) && (
				<div class="intro">
					{heading && (
						<Heading class="heading" id={headingId}>
							{heading}
						</Heading>
					)}
					{intro && <p class="lead">{intro}</p>}
				</div>
			)
		}

		<ul class="grid" role="list">
			{
				cards.map((item) => (
					<li class="card">
						<span class="tile" aria-hidden="true">
							<svg
								class="glyph"
								viewBox="0 0 24 24"
								fill="none"
								stroke="currentColor"
								stroke-width="2"
								stroke-linecap="round"
								stroke-linejoin="round"
								focusable="false"
							>
								{pathsFor(item).map((d) => (
									<path d={d} />
								))}
							</svg>
						</span>
						<h3 class="card-title">{item.title}</h3>
						{item.body && <p class="card-body">{item.body}</p>}
					</li>
				))
			}
		</ul>
	</div>
</section>
)}

<style>
	.feature-grid {
		/* Derived from --ifg-ink so one override re-tints the body copy with
		   the titles instead of leaving it behind at the old hue. */
		--ifg-muted: color-mix(in srgb, var(--ifg-ink) 58%, transparent);
		--ifg-rule: color-mix(in srgb, var(--ifg-ink) 12%, transparent);

		--ifg-gap: 8px;
		--ifg-pad: 16px;
		--ifg-radius: 12px;
		--ifg-duration: 300ms;

		box-sizing: border-box;
		display: block;
		background: var(--ifg-surface);
		color: var(--ifg-ink);
		font-family: var(--ifg-font);
		/* A host page with its own `line-height: 1` would otherwise collapse
		   every measurement below. */
		line-height: 1.5;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.feature-grid :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--ifg-maxw);
		margin-inline: auto;
		padding: 48px 16px 16px;
	}

	/* The rails belong to the page rhythm, not to the card grid: they run down
	   the shell's edges so a stack of sections reads as one ruled column. */
	.feature-grid[data-rules] .shell {
		border-top: 1px dashed var(--ifg-rule);
		border-left: 1px dashed var(--ifg-rule);
		border-right: 1px dashed var(--ifg-rule);
	}

	/* ---------- lead-in ---------- */

	.intro {
		max-width: 36rem;
		text-wrap: balance;
	}

	.heading {
		margin: 0;
		font-family: var(--ifg-heading-font);
		font-size: 1.125rem;
		line-height: 1.75rem;
		font-weight: 500;
		letter-spacing: -0.025em;
		color: var(--ifg-ink);
	}

	.lead {
		margin: 1rem 0 0;
		font-size: 1rem;
		line-height: 1.5rem;
		color: var(--ifg-muted);
	}

	/* ---------- grid ---------- */

	.grid {
		display: grid;
		grid-template-columns: 1fr;
		gap: var(--ifg-gap);
		margin: 2rem 0 0;
		padding: 0;
		list-style: none;
	}

	.card {
		display: flex;
		flex-direction: column;
		/* Rows are sized by their tallest card and every card fills its row, so
		   the tinted blocks line up even when one title wraps to two lines. */
		height: 100%;
		padding: var(--ifg-pad);
		border-radius: var(--ifg-radius);
		background: var(--ifg-card);
		/* Drawn at rest in the resting colour so the hovered state changes the
		   outline's colour rather than adding a box, which would shift layout. */
		outline: 1px solid transparent;
		transition:
			background-color var(--ifg-duration) ease,
			box-shadow var(--ifg-duration) ease,
			outline-color var(--ifg-duration) ease;
	}

	/* `focus-within` is not in the reference. It is here so that a project which
	   puts a link inside a card gets the same state from the keyboard that a
	   mouse gets, rather than a card that never responds to tabbing. */
	.card:hover,
	.card:focus-within {
		background: var(--ifg-tile);
		outline-color: var(--ifg-card);
		box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
	}

	.tile {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: fit-content;
		padding: var(--ifg-pad);
		border-radius: 8px;
		background: var(--ifg-tile);
		color: var(--ifg-accent);
		transition:
			background-color var(--ifg-duration) ease,
			rotate var(--ifg-duration) ease,
			translate var(--ifg-duration) ease;
	}

	.card:hover .tile,
	.card:focus-within .tile {
		background: var(--ifg-card);
		rotate: -12deg;
		translate: 0 -8px;
	}

	/* Scoped to the component's own element: every glyph is written by this
	   file, whether its paths are built in or supplied, so the rule needs no
	   `:global` escape hatch to reach it. */
	.glyph {
		display: block;
		width: 20px;
		height: 20px;
		/* A host stylesheet that fills every svg would otherwise flood the
		   outline shapes. */
		fill: none;
		stroke: currentColor;
	}

	.card-title {
		/* The band of empty card between tile and title is the whole look. */
		margin: 3rem 0 0;
		font-size: 0.875rem;
		line-height: 1.25rem;
		font-weight: 500;
		color: var(--ifg-ink);
	}

	.card-body {
		margin: 0.5rem 0 0;
		font-size: 0.875rem;
		line-height: 1.25rem;
		color: var(--ifg-muted);
	}

	/* ---------- measured breakpoints ----------
	   640 / 768 / 1024 / 1536, probed either side on the reference. The grid
	   goes 1 -> 2 -> N and never stops at 3. */

	@media (min-width: 640px) {
		.heading {
			font-size: 1.25rem;
			line-height: 1.75rem;
		}
	}

	@media (min-width: 768px) {
		.heading {
			font-size: 1.5rem;
			line-height: 2rem;
		}

		.grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	@media (min-width: 1024px) {
		.heading {
			font-size: 1.875rem;
			line-height: 2.25rem;
		}

		.grid {
			grid-template-columns: repeat(var(--ifg-cols), minmax(0, 1fr));
		}
	}

	/* The reference widens its shell and its gutters together at 1536px. */
	@media (min-width: 1536px) {
		.shell {
			max-width: calc(var(--ifg-maxw) + 8rem);
			padding-inline: 48px;
		}
	}

	/* The reference animates regardless of the setting. Honouring it is a
	   deliberate change: the states still swap, they just arrive at once. */
	@media (prefers-reduced-motion: reduce) {
		.card,
		.tile {
			transition-duration: 1ms;
		}

		.card:hover .tile,
		.card:focus-within .tile {
			rotate: none;
			translate: none;
		}
	}
</style>

===== END src/components/IconFeatureGrid.astro =====

===== FILE (supporting): src/components/IconFeatureGrid.md =====
# Icon feature grid

A short editorial lead-in above a dense grid of icon-topped feature cards. Each
card is tinted and carries a white icon tile at its top; the title starts 48px
below the tile, so every card has a band of empty colour that the tile appears
to float in. Under the pointer the card and its tile swap grounds — the card
goes white with a deep shadow, the tile takes the card's tint — and the tile
tips 12 degrees and lifts 8px at the same moment.

Catalog page: `/components/icon-feature-grid`

## What this was built from

Reference: the fourth top-level section of the Brightlight demo page at
`https://brightlight-astro.pages.dev/`, selected with
`document.querySelectorAll("body > main > section:nth-child(4)")`.

- **Match count:** exactly 1 element.
- **Section type:** a **features** section — a two-part block of heading plus
  lead paragraph, followed by a uniform grid of eight icon/title/body cards. No
  pricing, no testimonials, no comparison, no call to action, no links.
- **Component boundary:** the matched `<section>` and everything inside it. The
  page's header, announcement bar, the sections before and after it, the footer
  and the Lexington preview toolbar are all outside the boundary and are not
  reproduced.

### Ancestor behaviour incorporated

The section itself is a bare `display: block` element with no padding,
background or max width — every constraint lives on its own first child. Three
inherited behaviours had to travel with it:

| Inherited from | What it did | How it is reproduced |
| :--- | :--- | :--- |
| `body` | White page ground, 16px/24px sans body type, black default ink | `surface`, `--ifg-font`, `line-height: 1.5` set on the component root |
| `body` | Serif display face for headings, sans everywhere else | `headingFont` (generic serif stack) against `font` |
| the section's own shell | 1024px max width, `48px 16px 16px` padding, dashed rails on the left, right and top edges | the `.shell` element and the `rules` prop |

`main` contributed nothing: no max width, no padding, no positioning, no
overflow rule. Nothing else from an ancestor is copied into the component.

## Reference measurements

Taken at 1440px unless noted.

| | Reference | This component |
| :--- | :--- | :--- |
| Section padding | none — the shell carries it | same |
| Shell max width | 1024px (1152px from 1536px up) | same, via `maxWidth` |
| Shell padding | 48px top, 16px sides, 16px bottom (48px sides from 1536px up) | same |
| Shell rails | 1px dashed `#e5e5e5` on top, left, right | 1px dashed, derived from `ink` at 12% |
| Lead-in block | 576px max width, `text-wrap: balance` | same |
| Heading | serif, 30px/36px, weight 500, tracking −0.025em, `#000` | same scale; colour follows `ink` |
| Lead paragraph | 16px/24px, `#737373`, 16px above | same; colour derived from `ink` |
| Grid | 4 columns, 8px gap, 32px above the grid | same |
| Column width | 241.67px at 1440 and 1280; 237.83px at 1024 | identical |
| Card | 16px padding, 12px radius, `#f7f5f0`, equal height per row | same |
| Icon tile | 52px square (16px padding around a 20px glyph), 8px radius, `#ffffff` | same |
| Glyph | 20px, 2px strokes, round caps and joins, `#e06040` | same |
| Title | 14px/20px, weight 500, `#171717`, 48px below the tile | same |
| Body | 14px/20px, `#737373`, 8px below the title | same |
| Card heights | 260px in row 1, 280px in row 2 (content-driven) | content-driven, same rule |
| Item count | 8 | 8 in the demo, any count supported |

Colours in the reference are authored in OKLCH; the hex values above are the
sRGB results and are what this component's defaults use.

## Exact responsive breakpoints

Probed one pixel either side of every change on the reference. They are
Tailwind's defaults, but they were measured rather than assumed:

| Width | What changes |
| :--- | :--- |
| **640px** | Heading 18px/28px → 20px/28px. Layout unchanged. |
| **768px** | Grid 1 column → 2 columns. Heading 20px/28px → 24px/32px. |
| **1024px** | Grid 2 columns → 4 columns. Heading 24px/32px → 30px/36px. |
| **1536px** | Shell max width 1024px → 1152px, side padding 16px → 48px. |

The grid goes **1 → 2 → 4 and never stops at 3**. Verified at 767/768,
1023/1024, 639/640 and 1535/1536.

### Layout transformations

Nothing is replaced, reordered or hidden at any width. The same markup serves
every breakpoint:

- Columns stack; they do not become a horizontal scroller.
- Card order never changes.
- No item is hidden at any width.
- Card internals — tile, 48px gap, title, body — are identical at 320px and at
  1440px. Only the column count and the heading size respond.
- At one column each card sizes to its own content; from two columns up the
  cards in a row share the tallest card's height.

## Interaction and animation

The section is **static**. Measured, not inferred: `getAnimations({subtree: true})`
returns 0, no descendant has a computed `animation-name`, the section contains
no `<script>`, no `<img>`, no `<video>` and no focusable element, and the page
renders identically with JavaScript disabled. There are no tabs, no accordion,
no carousel, no scroll snapping, no autoplay, no counters, no marquee, no
entrance animation, no scroll-triggered animation, no parallax and no
cursor-following effect. None of these were added.

The only real effect is card hover, measured before and after a real pointer
hover:

| | At rest | On hover |
| :--- | :--- | :--- |
| Card background | `#f7f5f0` | `#ffffff` |
| Card outline | 1px, transparent | 1px solid `#f7f5f0` |
| Card shadow | none | `0 25px 50px -12px rgba(0, 0, 0, 0.25)` |
| Tile background | `#ffffff` | `#f7f5f0` |
| Tile rotation | none | `-12deg` |
| Tile offset | none | `0 -8px` |

- **Trigger:** pointer over the card. **Duration:** 300ms. **Delay:** none.
  **Stagger:** none. **Easing:** `ease`. **Repeat:** none — it is a state, not
  a keyframe animation. **Reverse:** the same 300ms back on pointer-out.
- **No layout shift:** the card's box is measured identically at rest and on
  hover, and the neighbouring card does not move. The outline is drawn at rest
  in a transparent colour so hovering changes its colour rather than adding a
  box, and the tile animates with the `rotate` and `translate` properties,
  which do not affect layout.

### Reduced motion

The reference keeps its 300ms transitions under
`prefers-reduced-motion: reduce` — measured, not assumed. This component does
not. Under reduced motion:

- transitions collapse to 1ms, so the colour swap and the shadow arrive at once
  rather than easing in;
- the tile's rotation and lift are dropped entirely, since they are decoration
  and the colour swap already says which card is active.

The hover state itself still works, so nothing is lost. This is a deliberate
departure and the only one in the animation behaviour.

## Props

Every prop is optional, but there is no demo content: with no `items` the
component renders nothing, and a heading alone never makes it render. The
eight cards on the catalog page are passed as props.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | `""` | Section heading. `""` hides it. |
| `intro` | `string` | `""` | Lead paragraph under the heading. `""` hides it. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong rank. |
| `items` | `FeatureItem[]` | `[]` | The cards. Any count works. A card with no `title` is dropped. |
| `columns` | `2 \| 3 \| 4` | `4` | Columns from 1024px up. Below that it is always 2, then 1 under 768px. |
| `surface` | `string` | `"#ffffff"` | Page ground the section paints behind itself. |
| `cardSurface` | `string` | `"#f7f5f0"` | Card ground at rest, and the tile's ground on hover. |
| `tileSurface` | `string` | `"#ffffff"` | Tile ground at rest, and the card's ground on hover. |
| `accent` | `string` | `"#e06040"` | Glyph colour. |
| `ink` | `string` | `"#171717"` | Title colour. The muted body tone and the rail colour are derived from it. |
| `rules` | `boolean` | `true` | Dashed rails down the shell's edges and across its top. `false` for a section that stands alone. |
| `maxWidth` | `string` | `"64rem"` | Shell max width. Widens by 8rem from 1536px up, as the reference does. |
| `font` | `string` | system sans stack | Body font stack. |
| `headingFont` | `string` | system serif stack | Heading font stack. |
| `class` | `string` | — | Extra class on the section element. |
| `id` | `string` | — | Section id. Also seeds the heading's id instead of a random one. |

### `FeatureItem`

| Field | Type | What it does |
| :--- | :--- | :--- |
| `title` | `string` | Card title. The only required field. |
| `body` | `string?` | Supporting paragraph. Omit it for a title-only card. |
| `icon` | `FeatureIcon?` | Which built-in glyph to draw. |
| `iconPaths` | `string[]?` | SVG path `d` values for a glyph the component does not ship. **Path data, not markup.** |

`FeatureIcon` is one of `"orbit"`, `"pulse"`, `"layers"`, `"exchange"`,
`"target"`, `"brackets"`, `"frame"`, `"gauge"`, `"spark"`.

### Which glyph a card draws

In order:

1. **`icon`**, when it names one of the nine built-ins.
2. **`iconPaths`**, when `icon` names no built-in and the array holds at least
   one non-blank string. Blank entries are dropped, so a list of nothing but
   whitespace does not draw an empty tile.
3. **`spark`**, the fallback, for everything else — no icon, an unrecognised
   name, an empty array, or no `iconPaths` at all.

### `iconPaths` takes path data, not SVG

Each string is one `<path>` element's `d` attribute — the same kind of value
the built-ins are written as, on the same 24×24 grid:

```astro
<IconFeatureGrid
  items={[
    {
      title: "A glyph of your own",
      // Two `d` values. Not "<svg>…</svg>", not "<path d='…' />".
      iconPaths: ["M4 12h16", "M12 4v16"],
    },
  ]}
/>
```

The component writes the `<svg>` itself and passes each string straight to a
`d` attribute. It does **not** parse, unwrap or render complete `<svg>`
strings, HTML, event attributes, `<script>`, `<foreignObject>`, `<image>` or
any external reference — a string containing any of those is simply invalid
path data and draws nothing. Glyph size, stroke width, colour, `aria-hidden`
and `focusable` are the component's, not the caller's, so a custom glyph lands
exactly where a built-in one would.

`cardSurface` and `tileSurface` are a pair: they trade places on hover, so
setting one without the other breaks the effect rather than half-applying it.

## Copying it into another Astro project

Copy **`IconFeatureGrid.astro`** — one file, the whole component.

- No imports — not from this catalog, not from anywhere.
- No companion files, no npm packages, no Astro built-ins.
- No global stylesheet. Every colour, size, breakpoint and duration it needs is
  declared on its own root element as an `--ifg-*` custom property, so it
  inherits nothing from the host page and leaks nothing back.
- No Tailwind. No webfonts, no images, no network requests of any kind.
- No JavaScript at all — not a script tag, not a client directive.
- The only assumption about the host is that Astro compiles `.astro` files.

Drop it in and render it:

```astro
---
import IconFeatureGrid from "../components/IconFeatureGrid.astro";
---

<IconFeatureGrid
  heading="What you get on day one"
  intro="Four things that are usually an upgrade, shipped as the default."
  columns={4}
  items={[
    { icon: "orbit", title: "Sandbox mode", body: "Fail loudly, safely." },
    { icon: "pulse", title: "Callbacks", body: "We post the moment it happens." },
    { icon: "layers", title: "Readable logs", body: "Every call, in order." },
    { icon: "gauge", title: "Your domain", body: "Verify once, send as you." },
  ]}
/>
```

## Assets

**None.** The section needs no imagery: the only artwork is nine line glyphs
drawn as path data inside the component, so there is nothing in `public/` to
copy and nothing to register in the catalog. The glyphs are original abstract
shapes on a 24×24 grid, not a traced icon set.

**Fallbacks.** An item with no `icon` — or with a name that is not in the set,
which is a typo rather than a reason to leave a hole in the card — draws the
`spark` glyph, as does an item whose `iconPaths` is empty or blank. Because
every glyph is inline path data rather than a file, none of them can 404 and
none can shift the layout while it loads.

## Customization

- **Copy:** `heading`, `intro`, and each item's `title` and `body`.
- **Colours:** `surface`, `cardSurface`, `tileSurface`, `accent`, `ink`. The
  muted body tone and the rail colour are mixed from `ink`, so one override
  re-tints the section rather than leaving half of it behind.
- **Typography:** `font` and `headingFont` take any stack. The type scale
  itself is fixed — it is the measured design.
- **Icons:** name a built-in with `icon`, or pass your own SVG path `d`
  values with `iconPaths`. Path data only — see above.
- **Density:** `columns` and the length of `items`.
- **Page rhythm:** `rules` and `maxWidth`.

Links and buttons are deliberately not offered. The reference section has no
interactive controls of any kind, and adding a link per card would change what
the section is.

## Accessibility

The reference section is a set of `<div>`s with decorative SVGs that carry no
`aria-hidden`. The improvements over it are listed here rather than folded in
silently:

| | Reference | This component |
| :--- | :--- | :--- |
| Section labelling | none | `aria-labelledby` pointing at the heading |
| Card grouping | eight sibling `<div>`s | one `<ul role="list">` of `<li>` cards, so a screen reader announces "list, 8 items" |
| Heading rank | fixed `h2`/`h3` | `headingLevel` prop, so the section fits the page's outline |
| Decorative glyphs | plain `<svg>` | `aria-hidden="true"` on the tile and `focusable="false"` on the glyph, so the icons are skipped rather than read as unlabelled graphics |
| Keyboard equivalent of hover | none | `:focus-within` mirrors the hover state |
| Reduced motion | ignored | honoured |

Notes on the checks that do not apply here:

- **Focus indicators and tab order:** the component contains no links, buttons,
  inputs or `tabindex` attributes — verified in the rendered DOM — so there is
  nothing to focus and no tab order to get wrong. It never removes or overrides
  a host page's focus styling.
- **44×44px targets:** there are no interactive targets. The icon tile, the
  largest discrete element inside a card, is 52×52px.
- **Escape behaviour and focus return:** nothing opens, so neither applies.
- **Hover:** the hover state is decoration. It reveals no content and conveys
  no information that is not already on the card, so a pointer is never
  required to read the section.

## No JavaScript

The component ships no script. With JavaScript disabled the rendered section is
byte-for-byte the same and measures identically — 760.67px tall at 1440px in
both cases. Hover still works, because it is CSS.

## Multiple instances

Two or more on a page are independent, verified in a two-instance render:

- The only id minted is the heading's, derived from a per-instance uid
  (`ifg-xxxxxxx-heading`), so no ids collide. Passing `id` seeds it instead.
- Every style is scoped by Astro to the instance's own elements; hovering a
  card in one instance leaves the other untouched.
- Props do not leak: one instance rendered four light columns with rails while
  the other rendered three dark columns without them, on the same page.

## Safety against a host page's CSS

- `box-sizing: border-box` is re-declared through `:where(...)`, which keeps
  specificity at zero so a host's own rules still win where they mean to.
- `line-height: 1.5` is set on the root, because a host with `line-height: 1`
  would otherwise collapse every vertical measurement in the section.
- The glyph rule pins `fill: none` and `stroke: currentColor`, so a host
  stylesheet that fills every `svg` cannot flood the outline shapes.
- Every selector is scoped to the component's own root. There are no
  document-wide selectors and no global styles.

## Measurable differences from the reference

1. **Reduced motion is honoured** (see above). The reference animates
   regardless.
2. **Cards are list items.** The reference uses `<div>`s; the visual result is
   identical, the announced result is not.
3. **`align-items: center` plus `height: 100%` became `stretch`.** The
   reference's pair nets out to stretched, equal-height cards; one declaration
   produces the same measured boxes.
4. **Rail width is 1px.** The reference's rails compute to 0.667px in Chrome.
   The difference is invisible at any zoom level tested and 1px is the honest
   value to write down.
5. **Fonts are generic stacks.** The reference loads two webfonts. This
   component loads none and uses the system serif and sans stacks, so the
   heading's line breaks may fall differently. The sizes, weights, tracking and
   line heights are the measured ones.
6. **Content is entirely different.** See below.

Everything else — the shell, the rails, the grid, the breakpoints, the card
box, the 48px gap, the tile, the hover trade and its timing — matches the
measured reference.

## Originality and licensing

Brightlight is a paid Lexington Themes product. This component was rebuilt from
measurements taken in a browser against the public demo page; no theme source
file was obtained, opened or copied.

Not used: any Lexington or Brightlight source code, Tailwind class strings,
brand names, logos, marketing copy, illustrations, photographs, icons, fonts,
testimonials, company details, destination URLs, analytics or third-party
scripts. The name, the slug, the class names, the props, the custom-property
prefix and the demo content share nothing with the theme.

The demonstration copy lives in the catalog's props, not in the component. It
is fictional and describes an imaginary developer product. It contains no statistics, customer statements, endorsements or
verifiable claims of any kind — deliberately, so nothing here can be mistaken
for a real result.

===== END src/components/IconFeatureGrid.md =====

--- VERIFICATION ---
This component is not enrolled in the library's verification system, so there is no
recorded evidence about it either way. Treat it as unverified and test it yourself.

--- TEST IN THIS PROJECT AFTER INSTALLING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Icon feature grid” from my Astro Component Library:
https://astro.baysixmedia.com/components/icon-feature-grid/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

src/components/library/IconFeatureGrid.astro

one fileNo imports, no companion files, no packages. Copy IconFeatureGrid.astro into any Astro project's components folder and render it.

---
/**
 * IconFeatureGrid — a short editorial lead-in above a dense grid of
 * icon-topped feature cards that trade colours with their icon tile under
 * the pointer.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts, no images, no network requests of any kind: drop it into any
 * Astro project and render it.
 *
 * Two things carry the design. The first is the gap: the icon tile sits at the
 * top of the card and the title starts 48px below it, so every card has a band
 * of empty colour in the middle that the tile appears to float in. The second
 * is the hover trade — the card is tinted and the tile is white at rest, and on
 * hover they swap, so the tile reads as a hole punched through the card rather
 * than a badge sitting on it. The tile tips and lifts at the same moment.
 *
 * Measured behaviour (reproduced from a 1440px reference render):
 *   shell        1024px max width, 48px / 16px / 16px padding, dashed rails
 *   columns      1 -> 2 at >=768px -> 4 at >=1024px (never 3)
 *   grid         8px gap, 32px above, equal-height rows
 *   card         16px padding, 12px radius, tinted ground
 *   tile         52px square, 8px radius, 20px glyph, 2px strokes
 *   rhythm       tile -> 48px -> 14px title -> 8px -> 14px body
 *   hover        card and tile swap grounds; tile rotates -12deg, rises 8px
 *   timing       300ms ease on every hover property, no delay, no stagger
 *   shadow       0 25px 50px -12px rgba(0, 0, 0, 0.25) on the hovered card
 *
 * The section is otherwise static: no scripts, no scroll effects, no entrance
 * animation, nothing that runs on a timer.
 *
 * Several instances can share a page. The only id the component mints is the
 * one joining the section to its heading, and it is derived from an instance
 * uid rather than written into the markup as a constant.
 *
 * Every element here is written by this file. Nothing a caller passes is
 * rendered as markup: a custom glyph arrives as SVG path `d` strings, which
 * become attributes of paths the component writes, so item data from a CMS or
 * an API cannot introduce an element, a handler or a request.
 */

/** A glyph drawn by this component, named for its shape rather than a meaning. */
export type FeatureIcon =
	| "orbit"
	| "pulse"
	| "layers"
	| "exchange"
	| "target"
	| "brackets"
	| "frame"
	| "gauge"
	| "spark";

export interface FeatureItem {
	/** Card title. The only required field. */
	title: string;
	/** Supporting paragraph under the title. Optional. */
	body?: string;
	/** Which built-in glyph to draw. Unknown or missing falls back to "spark". */
	icon?: FeatureIcon;
	/**
	 * SVG path `d` values for a glyph this component does not ship — one
	 * string per `<path>`, drawn on the same 24x24 grid as the built-ins.
	 *
	 * Path data only, never markup: a complete `<svg>` string, an `<image>`,
	 * an event attribute or anything else is not parsed, not unwrapped and not
	 * rendered. Each string becomes the `d` attribute of a `<path>` the
	 * component writes itself, so a value that arrives from a CMS or an API
	 * can draw a wrong shape but cannot introduce an element or a handler.
	 *
	 * Used only when `icon` names no built-in. Empty, blank or absent falls
	 * back to "spark".
	 */
	iconPaths?: string[];
}

interface Props {
	/** Section heading. Empty string hides it. */
	heading?: string;
	/** Paragraph under the heading. Empty string hides it. */
	intro?: string;
	/** Heading level, for pages where h2 is the wrong rank. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The cards. Any count works; the grid fills rows left to right. */
	items?: FeatureItem[];
	/** Columns from 1024px up. Below that it is always 2, then 1 under 768px. */
	columns?: 2 | 3 | 4;
	/** Page ground the section paints behind itself. */
	surface?: string;
	/** Card ground at rest. Becomes the tile's ground on hover. */
	cardSurface?: string;
	/** Tile ground at rest. Becomes the card's ground on hover. */
	tileSurface?: string;
	/** Glyph colour. */
	accent?: string;
	/** Title colour. The muted body tone is derived from it. */
	ink?: string;
	/**
	 * Dashed rails down the shell's edges and across its top, which line the
	 * section up with the ones above and below it. Off for a section that
	 * stands alone.
	 */
	rules?: boolean;
	/** Shell max width. The reference sits at 64rem. */
	maxWidth?: string;
	/** Body font stack. */
	font?: string;
	/** Heading font stack. Serif in the reference, against a sans body. */
	headingFont?: string;
	class?: string;
	id?: string;
}

/* Annotated rather than inferred. Dropping the annotation checks clean today
   -- measured, not assumed -- but during this component's development the
   checker's `Props`-to-`Astro.props` wiring proved sensitive to where things
   sit in the frontmatter, and it fails by silently typing every prop `any`
   rather than by complaining. One annotation is cheaper than that. */
const {
	heading = "",
	intro = "",
	headingLevel = "h2",
	items = [],
	columns = 4,
	surface = "#ffffff",
	cardSurface = "#f7f5f0",
	tileSurface = "#ffffff",
	accent = "#e06040",
	ink = "#171717",
	rules = true,
	maxWidth = "64rem",
	font = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
	headingFont = "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif",
	class: className,
	id,
}: Props = Astro.props;

const Heading = headingLevel;

/* No demo content: the cards and the lead-in are props. A card with no title
   is dropped; with no cards left nothing renders, and a heading alone never
   makes the section render. */
const cards = (items ?? []).filter((item) => item?.title);

/* Random rather than sequential: a module-level counter is shared by every
   render in the same server process, which is fine for one page and wrong the
   moment two pages are built in parallel and compared. */
const uid = id ?? `ifg-${Math.random().toString(36).slice(2, 9)}`;
const headingId = `${uid}-heading`;

/*
 * The glyphs. Drawn here as plain path data on a 24x24 grid with 2px strokes
 * and round joins — abstract shapes, not a traced icon set, so the component
 * carries no font and no sprite file and nothing to download.
 */
const glyphs: Record<FeatureIcon, string[]> = {
	orbit: ["M12 3.6a8.4 8.4 0 1 0 0 16.8 8.4 8.4 0 0 0 0-16.8Z", "M17.2 5.6a2.3 2.3 0 1 0 0 4.6 2.3 2.3 0 0 0 0-4.6Z"],
	pulse: ["M2.5 14h3.8l2.6-7.5L13 18l2.3-4h6.2"],
	layers: ["M12 3.2 3.4 7.4 12 11.6l8.6-4.2-8.6-4.2Z", "M3.4 12.2 12 16.4l8.6-4.2", "M3.4 16.8 12 21l8.6-4.2"],
	exchange: ["M7.5 20.5V3.5", "M3.8 7.2 7.5 3.5l3.7 3.7", "M16.5 3.5v17", "M20.2 16.8l-3.7 3.7-3.7-3.7"],
	target: ["M12 3.6a8.4 8.4 0 1 0 0 16.8 8.4 8.4 0 0 0 0-16.8Z", "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z", "M12 1.5v3.2M12 19.3v3.2M1.5 12h3.2M19.3 12h3.2"],
	brackets: ["M8.8 5.6 3.4 12l5.4 6.4", "M15.2 5.6 20.6 12l-5.4 6.4", "M13.4 3.8 10.6 20.2"],
	frame: ["M4 4.2h16a.8.8 0 0 1 .8.8v14a.8.8 0 0 1-.8.8H4a.8.8 0 0 1-.8-.8V5a.8.8 0 0 1 .8-.8Z", "M3.2 9.4h17.6", "M9.4 9.4v10.4"],
	gauge: ["M3.4 17.4a8.6 8.6 0 1 1 17.2 0", "M12 17.4l4.4-5.6", "M6.6 13.2h.01M12 8.6h.01M17.4 13.2h.01"],
	spark: ["M12 2.8v5.4M12 15.8v5.4M2.8 12h5.4M15.8 12h5.4", "M12 8.6a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z"],
};

/*
 * Which paths a card draws. A built-in name wins; failing that, a caller's own
 * path data; failing that, the fallback glyph — an unknown name is a typo, not
 * a reason to leave a hole in the card.
 *
 * `Array.isArray` rather than a plain lookup, because an `icon` value that
 * arrived as data rather than as a literal could name something inherited from
 * Object.prototype, and that must read as "not a built-in" rather than as a
 * function to map over.
 */
const pathsFor = (item: FeatureItem): string[] => {
	const named = item.icon ? glyphs[item.icon] : undefined;
	if (Array.isArray(named)) return named;

	/* Blank entries are dropped rather than emitted as empty paths, so a list
	   of nothing but whitespace falls back instead of drawing an empty tile. */
	const custom = (item.iconPaths ?? []).filter(
		(d): d is string => typeof d === "string" && d.trim() !== "",
	);
	return custom.length > 0 ? custom : glyphs.spark;
};

const styleVars = [
	`--ifg-surface:${surface}`,
	`--ifg-card:${cardSurface}`,
	`--ifg-tile:${tileSurface}`,
	`--ifg-accent:${accent}`,
	`--ifg-ink:${ink}`,
	`--ifg-maxw:${maxWidth}`,
	`--ifg-font:${font}`,
	`--ifg-heading-font:${headingFont}`,
	`--ifg-cols:${columns}`,
].join(";");
---

{cards.length > 0 && (
<section
	class:list={["feature-grid", className]}
	id={id}
	aria-labelledby={heading ? headingId : undefined}
	data-rules={rules ? "" : undefined}
	style={styleVars}
>
	<div class="shell">
		{
			(heading || intro) && (
				<div class="intro">
					{heading && (
						<Heading class="heading" id={headingId}>
							{heading}
						</Heading>
					)}
					{intro && <p class="lead">{intro}</p>}
				</div>
			)
		}

		<ul class="grid" role="list">
			{
				cards.map((item) => (
					<li class="card">
						<span class="tile" aria-hidden="true">
							<svg
								class="glyph"
								viewBox="0 0 24 24"
								fill="none"
								stroke="currentColor"
								stroke-width="2"
								stroke-linecap="round"
								stroke-linejoin="round"
								focusable="false"
							>
								{pathsFor(item).map((d) => (
									<path d={d} />
								))}
							</svg>
						</span>
						<h3 class="card-title">{item.title}</h3>
						{item.body && <p class="card-body">{item.body}</p>}
					</li>
				))
			}
		</ul>
	</div>
</section>
)}

<style>
	.feature-grid {
		/* Derived from --ifg-ink so one override re-tints the body copy with
		   the titles instead of leaving it behind at the old hue. */
		--ifg-muted: color-mix(in srgb, var(--ifg-ink) 58%, transparent);
		--ifg-rule: color-mix(in srgb, var(--ifg-ink) 12%, transparent);

		--ifg-gap: 8px;
		--ifg-pad: 16px;
		--ifg-radius: 12px;
		--ifg-duration: 300ms;

		box-sizing: border-box;
		display: block;
		background: var(--ifg-surface);
		color: var(--ifg-ink);
		font-family: var(--ifg-font);
		/* A host page with its own `line-height: 1` would otherwise collapse
		   every measurement below. */
		line-height: 1.5;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.feature-grid :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--ifg-maxw);
		margin-inline: auto;
		padding: 48px 16px 16px;
	}

	/* The rails belong to the page rhythm, not to the card grid: they run down
	   the shell's edges so a stack of sections reads as one ruled column. */
	.feature-grid[data-rules] .shell {
		border-top: 1px dashed var(--ifg-rule);
		border-left: 1px dashed var(--ifg-rule);
		border-right: 1px dashed var(--ifg-rule);
	}

	/* ---------- lead-in ---------- */

	.intro {
		max-width: 36rem;
		text-wrap: balance;
	}

	.heading {
		margin: 0;
		font-family: var(--ifg-heading-font);
		font-size: 1.125rem;
		line-height: 1.75rem;
		font-weight: 500;
		letter-spacing: -0.025em;
		color: var(--ifg-ink);
	}

	.lead {
		margin: 1rem 0 0;
		font-size: 1rem;
		line-height: 1.5rem;
		color: var(--ifg-muted);
	}

	/* ---------- grid ---------- */

	.grid {
		display: grid;
		grid-template-columns: 1fr;
		gap: var(--ifg-gap);
		margin: 2rem 0 0;
		padding: 0;
		list-style: none;
	}

	.card {
		display: flex;
		flex-direction: column;
		/* Rows are sized by their tallest card and every card fills its row, so
		   the tinted blocks line up even when one title wraps to two lines. */
		height: 100%;
		padding: var(--ifg-pad);
		border-radius: var(--ifg-radius);
		background: var(--ifg-card);
		/* Drawn at rest in the resting colour so the hovered state changes the
		   outline's colour rather than adding a box, which would shift layout. */
		outline: 1px solid transparent;
		transition:
			background-color var(--ifg-duration) ease,
			box-shadow var(--ifg-duration) ease,
			outline-color var(--ifg-duration) ease;
	}

	/* `focus-within` is not in the reference. It is here so that a project which
	   puts a link inside a card gets the same state from the keyboard that a
	   mouse gets, rather than a card that never responds to tabbing. */
	.card:hover,
	.card:focus-within {
		background: var(--ifg-tile);
		outline-color: var(--ifg-card);
		box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
	}

	.tile {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: fit-content;
		padding: var(--ifg-pad);
		border-radius: 8px;
		background: var(--ifg-tile);
		color: var(--ifg-accent);
		transition:
			background-color var(--ifg-duration) ease,
			rotate var(--ifg-duration) ease,
			translate var(--ifg-duration) ease;
	}

	.card:hover .tile,
	.card:focus-within .tile {
		background: var(--ifg-card);
		rotate: -12deg;
		translate: 0 -8px;
	}

	/* Scoped to the component's own element: every glyph is written by this
	   file, whether its paths are built in or supplied, so the rule needs no
	   `:global` escape hatch to reach it. */
	.glyph {
		display: block;
		width: 20px;
		height: 20px;
		/* A host stylesheet that fills every svg would otherwise flood the
		   outline shapes. */
		fill: none;
		stroke: currentColor;
	}

	.card-title {
		/* The band of empty card between tile and title is the whole look. */
		margin: 3rem 0 0;
		font-size: 0.875rem;
		line-height: 1.25rem;
		font-weight: 500;
		color: var(--ifg-ink);
	}

	.card-body {
		margin: 0.5rem 0 0;
		font-size: 0.875rem;
		line-height: 1.25rem;
		color: var(--ifg-muted);
	}

	/* ---------- measured breakpoints ----------
	   640 / 768 / 1024 / 1536, probed either side on the reference. The grid
	   goes 1 -> 2 -> N and never stops at 3. */

	@media (min-width: 640px) {
		.heading {
			font-size: 1.25rem;
			line-height: 1.75rem;
		}
	}

	@media (min-width: 768px) {
		.heading {
			font-size: 1.5rem;
			line-height: 2rem;
		}

		.grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
		}
	}

	@media (min-width: 1024px) {
		.heading {
			font-size: 1.875rem;
			line-height: 2.25rem;
		}

		.grid {
			grid-template-columns: repeat(var(--ifg-cols), minmax(0, 1fr));
		}
	}

	/* The reference widens its shell and its gutters together at 1536px. */
	@media (min-width: 1536px) {
		.shell {
			max-width: calc(var(--ifg-maxw) + 8rem);
			padding-inline: 48px;
		}
	}

	/* The reference animates regardless of the setting. Honouring it is a
	   deliberate change: the states still swap, they just arrive at once. */
	@media (prefers-reduced-motion: reduce) {
		.card,
		.tile {
			transition-duration: 1ms;
		}

		.card:hover .tile,
		.card:focus-within .tile {
			rotate: none;
			translate: none;
		}
	}
</style>