Three ways to get Capability 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
“Capability grid” from my Astro Component Library:
https://astro.baysixmedia.com/components/capability-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: Capability grid
Library page: https://astro.baysixmedia.com/components/capability-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 Capability grid from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/capability-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/CapabilityGrid.astro
one fileNo imports, no companion files, no packages. Copy CapabilityGrid.astro into any Astro project's components folder and render it.
---
/**
* 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;
}
const defaultItems: CapabilityItem[] = [
{
title: "One source for every token",
body: "Colour, spacing, radius and the type scale are declared once and read everywhere, so a change lands in a single edit.",
},
{
title: "Parts that travel",
body: "Each component carries its own styles and needs no global stylesheet, so moving it to another project is one file.",
},
{
title: "States drawn up front",
body: "Empty, loading, error and disabled are designed alongside the happy path instead of appearing when a ticket does.",
},
{
title: "Accessible by construction",
body: "Focus order, contrast and reduced-motion behaviour live inside the component rather than in a later audit.",
},
{
title: "Documentation beside the code",
body: "Every part ships with a README and a live demo at three widths, so usage notes cannot drift far from the source.",
},
];
const defaultNote: CapabilityNote = {
label: "Principle",
title: "Copyable beats clever",
body: "A part that needs three imports and a build step to run elsewhere is not reusable, only well organised. Everything here is meant to survive being pasted into a project that knows nothing about this one.",
};
const {
eyebrow = "02 — Capability",
heading = "What a shared library actually buys you.",
intro = "Five things it should do before it earns the name.",
items = defaultItems,
note = defaultNote,
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");
---
<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">
<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">
{
items.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>
))
}
{
note && (
<article class="cell panel" data-group>
{note.label && <span class="panel-label">{note.label}</span>}
<div class="panel-body">
<h3 class="cell-title" data-r-mask style="--d:0ms">
<span>{note.title}</span>
</h3>
{note.body && (
<p class="panel-text" data-r-up style="--d:60ms">
{note.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>