Component librarylocal catalog
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, all pre-filled with this component's name and library page.

Start here

Component reference

Paste it into 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 the branding and content open.

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.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Icon feature grid
Library page: https://astro.baysixmedia.com/components/icon-feature-grid

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Icon feature grid from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/icon-feature-grid

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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.

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;
}

const defaultItems: FeatureItem[] = [
	{
		icon: "orbit",
		title: "Sandbox mode",
		body: "Point the client at a throwaway workspace and let it fail loudly. Nothing you do in there reaches a real record.",
	},
	{
		icon: "pulse",
		title: "Callbacks on every event",
		body: "We post to your endpoint the moment something moves — queued, started, finished, abandoned — with the payload that caused it.",
	},
	{
		icon: "layers",
		title: "Readable request logs",
		body: "Every call, its response and the time it took, kept in order and searchable, so a bug report is a link rather than a story.",
	},
	{
		icon: "exchange",
		title: "Retries you can forget about",
		body: "A failed call backs off and comes round again on its own. You find out it happened from the log, not from a customer.",
	},
	{
		icon: "target",
		title: "Delivery you can measure",
		body: "Opens, clicks and bounces land on the same timeline as the send, so a drop in one is visible next to whatever caused it.",
	},
	{
		icon: "brackets",
		title: "Bring your own markup",
		body: "Markdown, a template language or hand-written HTML — whichever you hand us comes out the other side intact.",
	},
	{
		icon: "frame",
		title: "Preview before you send",
		body: "Render a draft against a set of common clients and read it the way the recipient will, including the ones that ignore half your CSS.",
	},
	{
		icon: "gauge",
		title: "Your domain, your reputation",
		body: "Verify a sending domain once and everything leaves under your own name instead of a shared subdomain nobody trusts.",
	},
];

/* 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 = "Built by a team that got tired of patching around the same gaps",
	intro = "Every part of this was something we needed and could not find, so we wrote it down, argued about it, and shipped it as a default rather than an add-on.",
	headingLevel = "h2",
	items = defaultItems,
	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;

/* 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(";");
---

<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">
			{
				items.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>