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.