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: "Process flow grid"
Library page: https://astro.baysixmedia.com/components/process-flow-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/ProcessFlowGrid.astro =====
---
/**
* ProcessFlowGrid — a sequence of steps laid out as a grid that reads back and
* forth, with arrows drawn through the gaps from each step to the next.
*
* One self-contained file. No imports, no global stylesheet, no npm packages,
* no webfonts, no images and no network requests of its own.
*
* The decisions that carry the weight, and why they are shaped this way:
*
* - **It is an ordered list, and it says so.** The steps are `<li>` inside an
* `<ol>`, so the order is in the markup rather than only in the arrows. The
* arrows are decorative and marked as such: they draw the sequence for
* someone who can see it, and the list carries it for everyone else.
*
* - **The serpentine is layout, not order.** Reading runs left to right along
* the first row and right to left along the next, so the eye never jumps
* back across the grid. That reversal is done by assigning each card a
* grid column — the document order is always 1, 2, 3, 4.
*
* - **Each arrow belongs to the card it leaves.** The reference draws all of
* them in one absolutely-positioned SVG over the grid, with a fixed
* `viewBox` and `preserveAspectRatio="none"`. That works while the cards
* are exactly the height it was drawn for, and stretches the arrows the
* moment somebody writes a longer step. Here each arrow is a small SVG
* anchored to its own card's edge and sized to the gap, so it lands in the
* right place whatever the content does to the row heights.
*
* - **Stacked, every arrow points down.** One column makes a back-and-forth
* meaningless, so the same connectors turn into a single vertical run. The
* shape swap is CSS; nothing is re-rendered.
*
* Several instances can share a page: there is no script, no state and no id
* collision surface. It is CSS and markup.
*/
export interface ProcessFlowStep {
/** The step's name. */
title: string;
/** What happens in it. */
body?: string;
}
export interface ProcessFlowGridProps {
/** The steps, in order. */
steps?: ProcessFlowStep[];
/**
* How many steps are done. Arrows leaving a completed step are drawn in
* full; the rest are drawn in the same colour held back, which is how the
* reference distinguishes them. Defaults to every step.
*/
completed?: number;
/** Accessible name for the list. */
label?: string;
/** Optional extra class for project-specific layout. */
class?: string;
/** Element id. */
id?: string;
}
interface Props extends ProcessFlowGridProps {}
const {
steps,
completed,
label = "Process",
class: className,
id,
}: Props = Astro.props;
const text = (value: unknown) => (typeof value === "string" ? value : "");
const list = (Array.isArray(steps) ? steps : []).filter(
(step) => step && text(step.title).trim() !== "",
);
/* No steps, nothing rendered: an empty grid, or a set of stand-in steps,
is not a process. */
const resolved = list;
/*
* Two columns is the shape this is: a back-and-forth needs exactly two, and a
* third would make the return leg cross the grid rather than step back one.
* It is a constant rather than a prop for that reason.
*/
const COLUMNS = 2;
/**
* Where each card sits, and which way it hands over to the next.
*
* Both fall out of the index. An even row runs left to right and an odd row
* runs right to left, so the column for an odd row is counted from the other
* end; the arrow is horizontal when the next card shares the row and vertical
* when it starts a new one.
*/
const cards = resolved.map((step, index) => {
const row = Math.floor(index / COLUMNS);
const place = index % COLUMNS;
const rightward = row % 2 === 0;
const last = index === resolved.length - 1;
const endsRow = place === COLUMNS - 1;
return {
title: text(step.title),
body: text(step.body),
/* 1-based, and mirrored on every other row. */
column: rightward ? place + 1 : COLUMNS - place,
/*
* The row is explicit too, and it has to be.
*
* Auto-placement only ever moves forwards: once the cursor has passed
* column 1 of a row it will not come back for it, so a card sent to
* column 1 on a reversed row lands on a NEW row instead and the grid
* grows a row per pair. Pinning both axes is what makes the
* back-and-forth a layout rather than a staircase.
*/
row: row + 1,
direction: last ? "none" : endsRow ? "down" : rightward ? "right" : "left",
/* An arrow is "live" when the step it leaves is done. */
live: typeof completed === "number" ? index < completed : true,
};
});
---
{cards.length > 0 && (
<div class:list={["process-flow-grid", className]} id={id}>
<ol class="pfg-list" aria-label={label}>
{cards.map((card) => (
<li
class="pfg-card"
style={`--pfg-column: ${card.column}; --pfg-row: ${card.row};`}
data-pfg-direction={card.direction}
data-pfg-live={card.live ? "" : undefined}
>
<h3 class="pfg-title">{card.title}</h3>
{card.body !== "" && <p class="pfg-body">{card.body}</p>}
{/*
Decorative. The order it illustrates is already in the <ol>,
so announcing it again would be telling the same thing twice
in a worse way.
*/}
{card.direction !== "none" && (
<span class="pfg-link" aria-hidden="true">
{/* Sized to the gap, so the stroke keeps its weight while the
box is stretched to fit. */}
<svg class="pfg-arrow pfg-arrow--across" viewBox="0 0 48 12" preserveAspectRatio="none">
<path d="M0 6h48M42 2.7 48 6l-6 3.3" vector-effect="non-scaling-stroke" />
</svg>
<svg class="pfg-arrow pfg-arrow--down" viewBox="0 0 12 48" preserveAspectRatio="none">
<path d="M6 0v48M2.7 42 6 48l3.3-6" vector-effect="non-scaling-stroke" />
</svg>
</span>
)}
</li>
))}
</ol>
</div>
)}
<style>
.process-flow-grid {
/* ---- overridable tokens -------------------------------------------
Set any of these on the element, from a class, an id or a wrapper
rule, to retune the component without touching this file. */
--pfg-card: 21rem; /* 336px */
--pfg-gap: 3rem; /* 48px */
--pfg-radius: 1.25rem; /* 20px */
--pfg-pad: 1.75rem; /* 28px */
--pfg-surface: #fff;
--pfg-edge: rgb(15 23 42 / 0.08);
--pfg-title: #0f172a;
--pfg-body: #475569;
--pfg-accent: #5865f2;
/* The same accent held back, for an arrow out of a step that is not
done yet. One colour, two strengths — not a second hue. */
--pfg-accent-quiet: rgb(88 101 242 / 0.18);
--pfg-shadow:
0 1px 2px rgb(15 23 42 / 0.06),
0 12px 32px rgb(15 23 42 / 0.07);
--pfg-lift: 4px;
--pfg-lift-duration: 220ms;
/* Below this the grid is one column and every arrow points down. */
--pfg-stack-at: 46rem;
box-sizing: border-box;
container-type: inline-size;
display: block;
color: var(--pfg-title);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
/* `:where` keeps specificity at zero, so a destination project's own rules
still win where it means them to. */
.process-flow-grid :where(*, *::before, *::after) {
box-sizing: border-box;
}
.pfg-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, var(--pfg-card)));
justify-content: center;
gap: var(--pfg-gap);
margin: 0;
padding: 0;
list-style: none;
}
.pfg-card {
/* Set per card, which is what turns the second row around. */
grid-column: var(--pfg-column);
grid-row: var(--pfg-row);
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: var(--pfg-pad);
background: var(--pfg-surface);
border: 1px solid var(--pfg-edge);
border-radius: var(--pfg-radius);
box-shadow: var(--pfg-shadow);
/*
* The individual property, not `transform`.
*
* A project that wants to add a transform of its own — a scale on
* focus, a skew in a parent — then does not have to reconstruct this
* one to avoid overwriting it.
*/
transition: translate var(--pfg-lift-duration) ease;
}
.pfg-card:hover {
translate: 0 calc(var(--pfg-lift) * -1);
}
.pfg-title {
margin: 0;
font-size: 1.25rem;
line-height: 1.3;
font-weight: 400;
letter-spacing: -0.01em;
color: var(--pfg-title);
}
.pfg-body {
margin: 0;
font-size: 0.9375rem;
line-height: 1.55;
color: var(--pfg-body);
text-wrap: pretty;
}
/* ---- the arrows ----------------------------------------------------- */
/*
* Anchored to the card it leaves and sized to the gap, so a taller row
* moves the arrow with it. Nothing here depends on the card's height being
* what it was when the component was drawn.
*/
.pfg-link {
position: absolute;
color: var(--pfg-accent-quiet);
pointer-events: none;
}
.pfg-card[data-pfg-live] .pfg-link {
color: var(--pfg-accent);
}
.pfg-arrow {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
}
/* One shape per axis rather than one shape rotated: a rotation would have
to be undone and re-centred at the breakpoint, and this is two paths. */
.pfg-arrow--down {
display: none;
}
.pfg-card[data-pfg-direction="right"] .pfg-link,
.pfg-card[data-pfg-direction="left"] .pfg-link {
top: 50%;
width: var(--pfg-gap);
height: 0.75rem;
translate: 0 -50%;
}
.pfg-card[data-pfg-direction="right"] .pfg-link {
left: 100%;
}
.pfg-card[data-pfg-direction="left"] .pfg-link {
right: 100%;
scale: -1 1;
}
.pfg-card[data-pfg-direction="down"] .pfg-link {
top: 100%;
left: 50%;
width: 0.75rem;
height: var(--pfg-gap);
translate: -50% 0;
}
.pfg-card[data-pfg-direction="down"] .pfg-arrow--across {
display: none;
}
.pfg-card[data-pfg-direction="down"] .pfg-arrow--down {
display: block;
}
/* ---- stacked --------------------------------------------------------
One column, so the back-and-forth is gone and every hand-off is the same
downward step. The cards keep their document order, which is why this is
only a matter of unsetting the column and re-pointing the arrows. */
@container (max-width: 46rem) {
.pfg-list {
grid-template-columns: minmax(0, var(--pfg-card));
}
.pfg-card {
grid-column: auto;
grid-row: auto;
}
.pfg-card[data-pfg-direction]:not([data-pfg-direction="none"]) .pfg-link {
top: 100%;
right: auto;
left: 50%;
width: 0.75rem;
height: var(--pfg-gap);
translate: -50% 0;
scale: 1 1;
}
.pfg-card .pfg-arrow--across {
display: none;
}
.pfg-card .pfg-arrow--down {
display: block;
}
}
/* ---- reduced motion --------------------------------------------------
The lift is the only motion here, and it is the whole hover treatment —
so it is removed rather than shortened. A 0ms translate is a jump, and a
jump is more movement than none. */
@media (prefers-reduced-motion: reduce) {
.pfg-card {
transition: none;
}
.pfg-card:hover {
translate: none;
}
}
</style>
===== END src/components/ProcessFlowGrid.astro =====
===== FILE (supporting): src/components/ProcessFlowGrid.md =====
# Process flow grid
A sequence of steps laid out as a grid that reads back and forth, with arrows
drawn through the gaps from each step to the next.
Catalog page: `/components/process-flow-grid`
## Purpose
For a process with three to six named steps that belong in one view — an
onboarding, a service, a way of working. The back-and-forth keeps a long
sequence compact without asking the eye to jump back across the page at the end
of every row.
Use a list if the steps are only names. This spends a whole card on each one,
and four cards carrying three words apiece look like a grid that lost its
content.
## Files required
**`ProcessFlowGrid.astro`** — one file, the whole component.
- **No script at all.** Markup and CSS; the arrows are inline SVG, not drawn at
runtime. There is nothing to boot, nothing to tear down, and nothing a
client-side router can strand.
- No imports, no npm packages, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size and duration is declared on the
component's own root element as a `--pfg-*` custom property.
- No webfonts, no images and **no network requests of its own**.
- The only assumption about the host is that Astro compiles `.astro` files.
`ProcessFlowGrid.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import ProcessFlowGrid from "../components/ProcessFlowGrid.astro";
---
<ProcessFlowGrid
label="How the work runs"
steps={[
{ title: "Frame", body: "We agree what the work has to achieve." },
{ title: "Shape", body: "Structure, type and colour settle into a system." },
{ title: "Build", body: "It ships as components, one at a time." },
{ title: "Tend", body: "Measured once it is live, then adjusted." },
]}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `steps` | `{ title, body? }[]?` | `[]` | The steps, in order. A step with no title is dropped. With no steps the component renders nothing. |
| `completed` | `number?` | every step | How many steps are done. Arrows leaving a completed step are drawn in full; the rest are drawn in the same colour held back. |
| `label` | `string?` | `"Process"` | Accessible name for the list. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Element id. |
```ts
import type { ProcessFlowGridProps, ProcessFlowStep } from "../components/ProcessFlowGrid.astro";
```
### Two columns, and no prop for it
The back-and-forth needs exactly two columns. A third would make the return leg
cross the whole grid rather than step back one, which is the thing this layout
exists to avoid — so the column count is a constant, not an option.
## Behaviour
| Input | What happens |
| :--- | :--- |
| Hover a card | It lifts 4px over 220ms. |
| Anything else | Nothing. There is no state, no timer and no script. |
The lift transitions the `translate` property rather than `transform`, so a
project that adds a transform of its own does not have to reconstruct this one
to avoid overwriting it.
### How the arrows are placed
Each arrow belongs to the card it leaves: a small SVG anchored to that card's
edge and sized to the grid gap. Nothing depends on the cards being a particular
height, so a longer step moves its arrow with it.
The alternative — one absolutely-positioned overlay across the whole grid with
a fixed `viewBox` — is what the reference does, and it stretches the arrows the
moment the content changes the row heights.
### The serpentine
Both the row and the column are set per card. Setting only the column does not
work: grid auto-placement moves forwards only, so a card sent back to column 1
on a reversed row lands on a **new** row instead, and the grid grows a row per
pair. That failure looked exactly like a layout bug and is why both axes are
pinned.
The document order is always 1, 2, 3, 4. Only the placement reverses.
## Accessibility
- An `<ol>` of `<li>`, so the sequence is in the markup and not only in the
arrows. The list carries the order for a screen reader; the arrows draw it for
everyone else.
- The arrows are `aria-hidden`, because announcing them would repeat what the
list already says, in a worse way.
- Nothing is focusable. The cards are content, not controls — there is no
keyboard interaction to get wrong, and no tab stop that leads nowhere.
- The hover lift is decorative and carries no information.
## Reduced motion
The lift is the only motion, and under `prefers-reduced-motion: reduce` it is
removed rather than shortened: a 0ms translate is a jump, and a jump is more
movement than none.
## Without JavaScript
Identical. The component ships none.
## Sizing
Cards are `21rem` (336px) with a `3rem` (48px) gap, two columns, centred. Below
a container width of `46rem` it becomes one column and every arrow turns to
point down — the back-and-forth is meaningless in a single column.
The breakpoint is a container query against the component, not the viewport, so
it behaves the same dropped into a narrow column.
```css
.my-section .process-flow-grid {
--pfg-card: 18rem;
--pfg-gap: 2.5rem;
--pfg-accent: #0f766e;
--pfg-radius: 0.5rem;
--pfg-lift: 0; /* no hover lift */
}
```
## Content length
- **Title** — one or two words. It is a step name, not a sentence.
- **Body** — 10 to 20 words. All cards in a row share a height, so one long step
sets the height for its neighbour.
## Differences from the reference
Rebuilt from a Framer page by measurement. The grid, the card, the palette, the
arrows and the hover lift are all matched to measurements of the original:
336px columns with a 48px gap, a 20px radius over a `rgb(15 23 42 / 0.08)`
hairline, `0 1px 2px` and `0 12px 32px` shadows, 20px/26px titles in `#0f172a`
over 15px/23.25px body in `#475569`, 3px round-capped arrows in `#5865f2`, and a
4px lift on `translate` over 220ms.
What differs:
- **No numbered badges.** They were excluded deliberately. The reference puts a
36px indigo circle on each card's top-left corner, which is why its cards
carry 40px of top padding against 28px elsewhere; with the badges gone that
clearance has no job, so the padding is even.
- **The arrows are per-card rather than one overlay**, for the reason above.
- **It is responsive.** The reference is a fixed 720px block with no stacked
layout.
- **The copy is original.** None of the reference's demo text is reproduced.
- The reference's connector path carries a partial dash — 83% of its length —
which appears to be a progress indicator. That idea is kept as the `completed`
prop, but expressed per arrow rather than as a fraction of one long path,
which is legible at any step count.
===== END src/components/ProcessFlowGrid.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-17 at 1440, 1280, 1024, 834, 768, 736, 640, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:07e547981abd8aa7b342a6faa34abfc6b78985ec1e3b64b94df94bb8830a9c06 — MATCHES the files below.
--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- 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.