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: "Capability grid"
Library page: https://astro.baysixmedia.com/components/capability-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/CapabilityGrid.astro =====
---
/**
* CapabilityGrid — a full-width editorial header over a hairline grid of
* numbered capability cells, closing on an inverted statement panel.
*
* One self-contained file. No imports, no global stylesheet, no npm packages:
* drop it into any Astro project and render it.
*
* The grid is drawn entirely with 1px cell borders rather than gaps, so the
* section reads as one ruled table instead of a row of floating cards. The
* closing panel occupies a normal cell and fills it edge to edge, which is
* what makes the inversion land.
*
* Measured behaviour:
* columns 1 -> 2 at >=768 -> 3 at >=1024 (configurable)
* cell rhythm 32px vertical padding, 24px inner inset, 280px min height
* numerals 16px, 0.24em tracking, muted — a label, not a display size
* hover bottom hairline wipes left to right over 700ms
* reveal 900ms mask-up per cell, 60ms stagger inside the cell
* easing cubic-bezier(0.16, 1, 0.3, 1) throughout
*
* Several instances can share a page: the reveal script scopes every query to
* the instance root and observes that instance's cells only.
*/
export interface CapabilityItem {
/** Used for the cell's element id. Falls back to no id. */
id?: string;
/** The capability, set in the cell's display size. */
title: string;
/** Supporting paragraph under the title. Optional. */
body?: string;
}
export interface CapabilityNote {
/** Small tracked label at the top of the panel, e.g. "Principle". */
label?: string;
title: string;
body?: string;
}
interface Props {
/** Small tracked label above the heading. Empty string hides it. */
eyebrow?: string;
/** Section heading. Empty string hides it. */
heading?: string;
/** Paragraph under the heading. Empty string hides it. */
intro?: string;
/** The numbered cells. Five plus the note fill a 3-column grid exactly. */
items?: CapabilityItem[];
/** Inverted closing panel in the last cell. Pass null to leave it out. */
note?: CapabilityNote | null;
/** Columns from the desktop breakpoint up. Two and three are the tested pair. */
columns?: 2 | 3 | 4;
/** First number in the sequence, for a grid that continues another. */
numberFrom?: number;
/** Minimum cell height at desktop, which keeps short cells from collapsing. */
minCellHeight?: string;
/** Section background. */
surface?: string;
/** Body and heading colour. Muted tones are derived from it. */
ink?: string;
/** Background of the inverted closing panel. */
panelSurface?: string;
/** Text colour inside the closing panel. */
panelInk?: string;
/** Animate cells in as they enter the viewport. */
reveal?: boolean;
/** Heading level, for pages where h2 is wrong. */
headingLevel?: "h1" | "h2" | "h3";
class?: string;
id?: string;
}
/* No demo content: the cells, the closing note and the header copy are all
props. The catalog preview passes its own. */
const {
eyebrow = "",
heading = "",
intro = "",
items = [],
note = null,
columns = 3,
numberFrom = 1,
minCellHeight = "280px",
surface = "#ffffff",
ink = "#14161a",
panelSurface = "#14161a",
panelInk = "#ffffff",
reveal = true,
headingLevel = "h2",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
const label = (i: number) => String(i + numberFrom).padStart(2, "0");
/* A cell or note with no title is dropped rather than drawn half-empty. With
no cells and no note there is nothing to show, so nothing renders — the
header copy alone never makes the section render. */
const cells = (items ?? []).filter((item) => item?.title);
const panel = note?.title ? note : null;
const hasIntro = Boolean(eyebrow || heading || intro);
---
{(cells.length > 0 || panel) && (
<section
class:list={["capabilities", className]}
id={id}
data-capabilities
data-cols={String(columns)}
data-reveal={reveal ? "" : undefined}
style={`--cg-surface:${surface};--cg-ink:${ink};--cg-panel-surface:${panelSurface};--cg-panel-ink:${panelInk};--cg-cols:${columns};--cg-min-cell:${minCellHeight};`}
>
<div class="shell">
{hasIntro && (
<div class="intro" data-group>
{
eyebrow && (
<p class="kicker">
<span class="kicker-text">{eyebrow}</span>
<span class="kicker-rule" data-r-rule />
</p>
)
}
{
heading && (
<Heading class="heading" data-r-mask style="--d:0ms">
<span>{heading}</span>
</Heading>
)
}
{
intro && (
<p class="intro-text" data-r-up style="--d:80ms">
{intro}
</p>
)
}
</div>
)}
<div class="grid">
{
cells.map((item, i) => (
<article class="cell" id={item.id} data-group>
<span class="num" aria-hidden="true">
{label(i)}
</span>
<h3 class="cell-title" data-r-mask style="--d:0ms">
<span>{item.title}</span>
</h3>
{item.body && (
<p class="cell-body" data-r-up style="--d:60ms">
{item.body}
</p>
)}
<span class="wipe" aria-hidden="true" />
</article>
))
}
{
panel && (
<article class="cell panel" data-group>
{panel.label && <span class="panel-label">{panel.label}</span>}
<div class="panel-body">
<h3 class="cell-title" data-r-mask style="--d:0ms">
<span>{panel.title}</span>
</h3>
{panel.body && (
<p class="panel-text" data-r-up style="--d:60ms">
{panel.body}
</p>
)}
</div>
</article>
)
}
</div>
</div>
</section>
)}
<style>
.capabilities {
/* Every tone is derived from --cg-ink, so one override re-tints the
whole section instead of leaving half of it behind. */
--cg-muted: color-mix(in srgb, var(--cg-ink) 52%, transparent);
--cg-detail: color-mix(in srgb, var(--cg-ink) 68%, transparent);
--cg-hairline: color-mix(in srgb, var(--cg-ink) 13%, transparent);
--cg-panel-muted: color-mix(in srgb, var(--cg-panel-ink) 50%, transparent);
--cg-panel-detail: color-mix(in srgb, var(--cg-panel-ink) 72%, transparent);
--cg-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
/* Fluid scale: 22px at a 390px viewport, 40px from 1280px up. */
--cg-display: clamp(1.375rem, 1.05rem + 1.6vw, 2.5rem);
--cg-title: clamp(1.125rem, 0.9rem + 1.1vw, 1.75rem);
--cg-body: 1rem;
--cg-gutter: clamp(20px, 5vw, 80px);
--cg-inset: 24px;
--cg-maxw: 1280px;
--cg-ease: cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
display: block;
padding: clamp(64px, 9vw, 128px) var(--cg-gutter);
background: var(--cg-surface);
color: var(--cg-ink);
font-family: var(--cg-font);
}
.capabilities :where(*, *::before, *::after) {
box-sizing: border-box;
}
.shell {
max-width: var(--cg-maxw);
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 64px;
}
/* ---------- header ---------- */
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.kicker {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
margin: 0;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--cg-muted);
}
.kicker-text {
flex-shrink: 0;
}
.kicker-rule {
flex: 1;
height: 1px;
background: var(--cg-hairline);
transform-origin: left center;
}
.heading {
margin: 0;
font-size: var(--cg-display);
line-height: 1.35;
font-weight: 650;
letter-spacing: -0.02em;
text-wrap: balance;
}
.intro-text {
margin: 0;
max-width: 46ch;
font-size: var(--cg-body);
line-height: 1.6;
color: var(--cg-detail);
}
/* ---------- grid ----------
* Cells are separated by their own borders, not by gaps, so the section
* reads as one ruled table. The outer edges are trimmed at the bottom of
* this block.
*/
.grid {
display: grid;
grid-template-columns: 1fr;
border-top: 1px solid var(--cg-hairline);
}
.cell {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
padding-block: 32px;
padding-right: var(--cg-inset);
border-bottom: 1px solid var(--cg-hairline);
}
.num {
font-size: 1rem;
font-weight: 500;
letter-spacing: 0.24em;
font-variant-numeric: tabular-nums;
color: var(--cg-muted);
}
.cell-title {
margin: 0;
font-size: var(--cg-title);
line-height: 1.35;
font-weight: 650;
letter-spacing: -0.015em;
text-wrap: balance;
}
.cell-body {
margin: 0;
font-size: var(--cg-body);
line-height: 1.6;
color: var(--cg-detail);
text-wrap: pretty;
}
/* The hover rule sits on the cell's own bottom edge and wipes across it. */
.wipe {
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 1px;
background: var(--cg-ink);
transform: scaleX(0);
transform-origin: left center;
transition: transform 700ms var(--cg-ease);
}
/* ---------- closing panel ---------- */
.panel {
gap: 24px;
justify-content: space-between;
padding: 32px var(--cg-inset);
background: var(--cg-panel-surface);
color: var(--cg-panel-ink);
border-bottom-color: transparent;
}
.panel-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--cg-panel-muted);
}
.panel-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.panel-text {
margin: 0;
font-size: var(--cg-body);
line-height: 1.75;
color: var(--cg-panel-detail);
text-wrap: pretty;
}
/* ---------- reveal ----------
* Hidden in CSS rather than armed by script, so the first paint is already
* correct and nothing flashes visible then snaps away. The reduced-motion
* and <noscript> escapes below cover the cases where it must not apply.
*/
.capabilities[data-reveal] [data-r-mask] {
overflow: hidden;
/* Give descenders the room the clip would otherwise cut. */
padding-bottom: 0.12em;
margin-bottom: -0.12em;
}
.capabilities[data-reveal] [data-r-mask] > span {
display: block;
transform: translateY(110%);
opacity: 0;
transition:
transform 900ms var(--cg-ease) var(--d, 0ms),
opacity 900ms var(--cg-ease) var(--d, 0ms);
}
.capabilities[data-reveal] [data-r-up] {
transform: translateY(28px);
opacity: 0;
transition:
transform 900ms var(--cg-ease) var(--d, 0ms),
opacity 900ms var(--cg-ease) var(--d, 0ms);
}
.capabilities[data-reveal] [data-r-rule] {
transform: scaleX(0);
transition: transform 1100ms var(--cg-ease);
}
.capabilities[data-reveal] [data-in] :is([data-r-mask] > span, [data-r-up]) {
transform: none;
opacity: 1;
}
.capabilities[data-reveal] [data-in] [data-r-rule] {
transform: scaleX(1);
}
/* ---------- breakpoints ---------- */
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
.cell {
padding-left: var(--cg-inset);
border-right: 1px solid var(--cg-hairline);
}
}
/*
* Row-edge trims. The cell that opens a row loses its left inset so the
* grid aligns with the header above it; the cell that closes a row loses
* its right border so the grid has no outer rule. The panel is excluded
* from the inset trim — it paints a background, so its text needs the
* padding wherever it lands.
*
* nth-child cannot read a custom property, so the column counts are
* written out. The tablet pair is bounded above rather than overridden,
* which keeps a 3-up desktop from inheriting 2-up trims.
*/
@media (min-width: 768px) and (max-width: 1023.98px) {
.cell:nth-child(2n + 1):not(.panel) {
padding-left: 0;
}
.cell:nth-child(2n) {
border-right: 0;
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(var(--cg-cols), 1fr);
}
.cell {
min-height: var(--cg-min-cell);
}
.panel {
padding-block: 48px;
}
.capabilities[data-cols="2"] .cell:nth-child(2n + 1):not(.panel) {
padding-left: 0;
}
.capabilities[data-cols="2"] .cell:nth-child(2n) {
border-right: 0;
}
.capabilities[data-cols="3"] .cell:nth-child(3n + 1):not(.panel) {
padding-left: 0;
}
.capabilities[data-cols="3"] .cell:nth-child(3n) {
border-right: 0;
}
.capabilities[data-cols="4"] .cell:nth-child(4n + 1):not(.panel) {
padding-left: 0;
}
.capabilities[data-cols="4"] .cell:nth-child(4n) {
border-right: 0;
}
}
/* The wipe is a pointer affordance. On a touch screen :hover sticks after
the finger lifts, which leaves a rule drawn under a cell for no reason. */
@media (hover: hover) and (pointer: fine) {
.cell:hover .wipe {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.capabilities[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
transform: none;
opacity: 1;
transition: none;
}
.capabilities[data-reveal] [data-r-rule] {
transform: scaleX(1);
transition: none;
}
.wipe {
transition: none;
}
}
</style>
<noscript>
<style>
.capabilities[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
transform: none;
opacity: 1;
}
.capabilities[data-reveal] [data-r-rule] {
transform: scaleX(1);
}
</style>
</noscript>
<script>
/*
* One observer per instance. Every query is scoped to the instance root,
* so two grids on a page reveal independently and never see each other's
* cells.
*/
const setup = (root: HTMLElement) => {
if (!root.hasAttribute("data-reveal")) return;
const groups = [...root.querySelectorAll<HTMLElement>("[data-group]")];
if (groups.length === 0) return;
/* Without IntersectionObserver there is no way to know when a cell
arrives. Show everything rather than leave the section blank. */
if (!("IntersectionObserver" in window)) {
groups.forEach((group) => group.setAttribute("data-in", ""));
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.setAttribute("data-in", "");
/* Reveal is one-way: a cell that has arrived stays arrived. */
observer.unobserve(entry.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0 },
);
groups.forEach((group) => observer.observe(group));
};
document.querySelectorAll<HTMLElement>("[data-capabilities]").forEach(setup);
</script>
===== END src/components/CapabilityGrid.astro =====
===== FILE (supporting): src/components/CapabilityGrid.md =====
# Capability grid
A full-width editorial header over a ruled grid of numbered cells, closing on
an inverted statement panel. Cells are separated by their own 1px borders
rather than by gaps, so the section reads as one ruled table instead of a row
of floating cards. A hairline wipes across a cell's bottom edge under the
pointer, and each cell rises into place as it enters the viewport.
Catalog page: `/components/capability-grid`
## Copying it into another Astro project
Copy **`CapabilityGrid.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 easing value it
needs is declared on its own root element as a `--cg-*` custom property, so
it inherits nothing from the host page and leaks nothing back.
- No webfonts, no images, no network requests of any kind.
- The only assumption about the host is that Astro compiles `.astro` files.
Drop it in and render it:
```astro
---
import CapabilityGrid from "../components/CapabilityGrid.astro";
---
<CapabilityGrid
eyebrow="02 — Capability"
heading="What a shared library actually buys you."
items={[
{ title: "One source for every token", body: "Declared once, read everywhere." },
{ title: "Parts that travel", body: "Each component carries its own styles." },
]}
note={{ label: "Principle", title: "Copyable beats clever" }}
/>
```
Every prop is optional, but there is no demo content: with no `items` and no
`note`, the component renders nothing, and header copy alone never makes it
render. The five cells and closing panel on the catalog page are passed as
props.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `CapabilityItem[]` | `[]` | The numbered cells. Any count works. A cell with no `title` is dropped. |
| `note` | `CapabilityNote \| null` | `null` | Inverted closing panel. Left out when `null` or without a `title`. |
| `columns` | `2 \| 3 \| 4` | `3` | Columns from `1024px` up. |
| `eyebrow` | `string` | `""` | Small tracked label above the heading. `""` hides it. |
| `heading` | `string` | `""` | Section heading. `""` hides it. |
| `intro` | `string` | `""` | Paragraph under the heading. `""` hides it. With all three empty the header block is left out. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong level. |
| `numberFrom` | `number` | `1` | First number in the sequence, for a grid continuing another. |
| `minCellHeight` | `string` | `"280px"` | Minimum cell height at desktop. |
| `surface` | `string` | `"#ffffff"` | Section background. |
| `ink` | `string` | `"#14161a"` | Text colour. Muted tones are derived from it. |
| `panelSurface` | `string` | `"#14161a"` | Background of the closing panel. |
| `panelInk` | `string` | `"#ffffff"` | Text colour inside the closing panel. |
| `reveal` | `boolean` | `true` | Animate cells in as they enter the viewport. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. |
### `CapabilityItem` and `CapabilityNote`
```ts
interface CapabilityItem {
id?: string; // element id for the cell; omit for none
title: string; // the capability
body?: string; // supporting paragraph
}
interface CapabilityNote {
label?: string; // small tracked label, e.g. "Principle"
title: string;
body?: string;
}
```
`ink` and `surface` re-tint the ruled cells; `panelInk` and `panelSurface` are
separate so the closing panel can invert independently. Within each pair the
muted, detail and hairline tones are `color-mix()` derivations, so one override
moves all of them together.
## Layout
| | ≥1024 | 768–1023 | <768 |
| :--- | :--- | :--- | :--- |
| Columns | `columns` (default 3) | 2 | 1 |
| Cell min height | 280px | auto | auto |
| Cell inset | 24px | 24px | 0 (panel keeps 24px) |
| Panel vertical padding | 48px | 32px | 32px |
| Heading | 40px | fluid | 22px |
| Cell title | 28px | fluid | 18px |
| Section gutter | `clamp(20px, 5vw, 80px)` | ← | ← |
### Row-edge trims
The grid is drawn with cell borders, which means the cells at the edge of a row
need their borders and insets trimmed or the section grows an outer rule and a
ragged left edge:
- the cell that **opens** a row loses its left inset, so the grid's first
column aligns with the heading above it instead of sitting 24px indented
- the cell that **closes** a row loses its right border, so the grid has no
outer vertical rule
- the **panel is excluded** from the inset trim — it paints a background, so
its text needs padding wherever in the row it lands
`nth-child()` cannot read a custom property, so the column counts are written
out per supported value. The two-column trims are bounded with `max-width`
rather than overridden, which stops a three-up desktop from inheriting them.
## Behaviour
- **Hover wipe:** a 1px rule on the cell's bottom edge scales from `0` to `1`
about its left edge over `700ms` on `cubic-bezier(0.16, 1, 0.3, 1)`. It is
gated behind `(hover: hover) and (pointer: fine)`, because on a touch screen
`:hover` sticks after the finger lifts and would leave a rule drawn under a
cell for no reason. The closing panel has no wipe.
- **Reveal:** each cell and the header block are observed independently. On
arrival the title rises out from behind a clipping mask over `900ms` and the
body follows `60ms` later. Reveal is one-way — a cell that has arrived stays
arrived, and its observer is dropped.
- **Reduced motion:** `prefers-reduced-motion: reduce` shows everything
immediately and removes the wipe transition.
- **Several per page:** every query is scoped to the instance root and each
instance gets its own `IntersectionObserver`, so two grids on a page reveal
independently and never see each other's cells.
### Why the hidden state is in CSS, not the script
The pre-reveal state is declared in the stylesheet and keyed on the root's
`data-reveal` attribute, rather than being applied by the script on load. If
the script armed it instead, the first paint would show the finished section
and then snap it back to hidden — a visible flash on every load.
The cost of that choice is that content hidden by CSS must be un-hidden by
something. Three escapes cover it:
- `prefers-reduced-motion: reduce` shows everything in CSS alone.
- A `<noscript>` block shows everything when scripting is off.
- If `IntersectionObserver` is missing, the script reveals every cell at once
rather than leaving the section blank.
Passing `reveal={false}` removes the attribute the hidden state is keyed on, so
the section renders fully static with no observer at all.
## Notes
The example copy is written for this catalog and is about what a shared
component library should provide. It is illustrative only — no real
organisation, product or quotation is involved, and nothing here carries
anyone's branding. Replace `items`, `note`, `heading` and `intro` with your own
and the design is unchanged.
===== END src/components/CapabilityGrid.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.