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: "Sticky process steps"
Library page: https://astro.baysixmedia.com/components/sticky-process-steps/
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/StickyProcessSteps.astro =====
---
/**
* Sticky process steps
*
* A process section: on the left, a sticky block with an eyebrow, a display
* heading and a pill link; on the right, numbered steps, each an image with a
* title and a line of text, threaded on a vertical line that fills as the
* section scrolls past the middle of the viewport.
*
* No script. The fill is a CSS scroll-driven animation on the line's own view
* timeline, inside `@supports`. Without support — and under reduced motion —
* the line is simply drawn full, which is the finished state. Below 1024px the
* sticky block becomes an ordinary header above the steps.
*
* Step images are props. Unset, each renders a labelled striped placeholder
* with its size, and the step's `hint` when one is passed.
*
* No demo content: steps, heading and the pill link are props. With no steps
* (or none with a title) the component renders nothing.
*
* Self-contained: no imports, no packages, no global stylesheet. Colours and
* fonts read the page's `--lx-*` tokens with fallbacks, so it also works alone.
*/
export interface ImageSlot {
/** Image URL. Unset renders the placeholder. */
src?: string;
/** Alt text. Decorative ("") by default — the step title carries the meaning. */
alt?: string;
/** What the photo should show. Printed on the placeholder. No default. */
hint?: string;
}
export interface ProcessStep {
/** Required: a step without a title is dropped. */
title: string;
text?: string;
/** Step image. Unset shows the placeholder; `null` drops the image. */
image?: ImageSlot | null;
}
interface Props {
/** Root id. */
id?: string;
/** The pill above the heading. `""` drops it. */
eyebrow?: string;
/** Section heading. No default; `""` drops it. */
heading?: string;
headingLevel?: "h2" | "h3";
/** The pill link. Drawn only when both label and href are set. */
ctaLabel?: string;
ctaHref?: string;
/** The steps. Empty (the default) renders nothing. */
steps?: ProcessStep[];
/** Colour of the progress fill on the line. The reference measures navy. */
fill?: "accent" | "navy";
/** Fill the line with scroll. `false` draws it full and still. */
animate?: boolean;
/** Drop the top padding, for a section that follows one with its own. */
flushTop?: boolean;
/** Webfont stylesheet. `""` if the page already loads Bebas Neue + Poppins. */
fontStylesheet?: string;
}
const {
id,
eyebrow = "Process",
heading = "",
headingLevel = "h2",
ctaLabel = "",
ctaHref = "",
steps = [],
fill = "navy",
animate = true,
flushTop = false,
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
} = Astro.props;
const Heading = headingLevel;
/* The step image at desktop: 457 x 235 in the reference. */
const IMAGE_SPEC = "~2:1 · 1400 × 720 px";
const StepHeading = headingLevel === "h2" ? "h3" : "h4";
const pad = (n: number) => String(n).padStart(2, "0");
/* A step with no title has nothing to show, so it is dropped rather than
rendered as an empty row. No steps, no section. */
const items = (steps ?? []).filter((step) => step?.title);
---
{items.length > 0 && (
<section
class:list={["sps", `sps--fill-${fill}`, { "sps--flush": flushTop }]}
id={id}
data-sps-root
data-animate={animate ? "" : undefined}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="sps__inner">
<div class="sps__intro">
{eyebrow && <p class="sps__eyebrow">{eyebrow}</p>}
<div class="sps__title-group">
{heading && <Heading class="sps__heading">{heading}</Heading>}
{
ctaLabel && ctaHref && (
<a class="sps__pill" href={ctaHref}>
<span>{ctaLabel}</span>
<span class="sps__pill-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span>
</a>
)
}
</div>
</div>
<div class="sps__steps">
<div class="sps__track" aria-hidden="true">
<span class="sps__fill"></span>
</div>
<ol class="sps__list">
{
items.map((step, i) => {
const image = step.image === undefined ? {} : step.image;
const hint = image?.hint ?? "";
return (
<li class="sps__step">
<span class="sps__badge" aria-hidden="true">{pad(i + 1)}</span>
<div class="sps__body">
{image && (
<div class="sps__media">
{image.src ? (
<img src={image.src} alt={image.alt ?? ""} loading="lazy" decoding="async" />
) : (
<div class="sps-ph" role="img" aria-label={hint ? `Image placeholder: ${hint}` : "Image placeholder"}>
<span class="sps-ph__label">Image</span>
{hint && <span class="sps-ph__hint">{hint}</span>}
<span class="sps-ph__spec">{IMAGE_SPEC}</span>
</div>
)}
</div>
)}
<div class="sps__copy">
<StepHeading class="sps__step-title">{step.title}</StepHeading>
{step.text && <p class="sps__step-text">{step.text}</p>}
</div>
</div>
</li>
);
})
}
</ol>
</div>
</div>
</section>
)}
<style>
.sps {
--sps-navy: var(--lx-navy, #0a2540);
--sps-accent: var(--lx-accent, #ff5f00);
--sps-ink-soft: var(--lx-ink-soft, #555555);
--sps-hairline: rgba(10, 37, 64, 0.14);
--sps-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--sps-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--sps-badge: 40px;
--sps-top: 100px;
box-sizing: border-box;
background: #fff;
padding: 60px 20px;
font-family: var(--sps-body);
color: var(--sps-navy);
overflow-x: clip;
}
.sps--fill-accent {
--sps-fill: var(--sps-accent);
}
.sps--fill-navy {
--sps-fill: var(--sps-navy);
}
.sps--flush {
padding-top: 0;
}
.sps *,
.sps *::before,
.sps *::after {
box-sizing: border-box;
}
.sps__inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 40px;
}
/* ---- intro ---- */
.sps__intro {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
}
.sps__eyebrow {
margin: 0;
padding: 7px 15px;
border: 1px solid var(--sps-hairline);
border-radius: 100px;
font: 500 14px/14px var(--sps-body);
text-transform: uppercase;
}
.sps__title-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
}
.sps__heading {
margin: 0;
font: 400 30px/1 var(--sps-display);
letter-spacing: -0.02em;
text-transform: uppercase;
}
.sps__pill {
display: inline-flex;
align-items: center;
gap: 16px;
min-height: 50px;
padding: 4px 4px 4px 18px;
border: 1px solid var(--sps-hairline);
border-radius: 100px;
background: #fff;
color: var(--sps-navy);
font: 600 14px/1 var(--sps-body);
text-decoration: none;
transition: border-color 0.2s ease;
}
.sps__pill-icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--sps-navy);
color: #fff;
transition: transform 0.25s ease;
}
.sps__pill:hover {
border-color: var(--sps-navy);
}
.sps__pill:hover .sps__pill-icon {
transform: translateX(3px);
}
.sps__pill:focus-visible {
outline: 2px solid var(--sps-navy);
outline-offset: 3px;
}
/* ---- steps ---- */
.sps__steps {
position: relative;
}
/*
* The line runs through the badge centres, from just under the first
* badge's top to the bottom of the list. It sits behind the badges, which
* have a white face, so it reads as joining them.
*/
.sps__track {
position: absolute;
top: 5px;
bottom: 3px;
left: calc(var(--sps-badge) / 2);
width: 1px;
background: var(--sps-hairline);
overflow: hidden;
}
/* Default: drawn full. This is the no-support and reduced-motion state. */
.sps__fill {
position: absolute;
inset: 0;
background: var(--sps-fill);
transform-origin: 50% 0;
}
.sps__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 32px;
}
.sps__step {
display: grid;
grid-template-columns: var(--sps-badge) minmax(0, 1fr);
align-items: start;
gap: 20px;
}
.sps__badge {
display: grid;
place-items: center;
width: var(--sps-badge);
height: var(--sps-badge);
margin-top: 3px;
border: 1px solid var(--sps-hairline);
border-radius: 50%;
background: #fff;
color: var(--sps-accent);
font: 400 20px/1 var(--sps-display);
letter-spacing: -0.02em;
/* Bebas sits high in its box; nudge the digits onto the optical centre. */
padding-top: 2px;
}
.sps__body {
display: flex;
flex-direction: column;
gap: 24px;
}
.sps__media {
overflow: hidden;
border-radius: 10px;
aspect-ratio: 457 / 235;
background: #eef2f6;
}
.sps__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ---- image placeholder (light context) ---- */
.sps-ph {
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
padding: 16px;
border: 1px dashed rgba(10, 37, 64, 0.22);
border-radius: inherit;
background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
color: rgba(10, 37, 64, 0.62);
text-align: center;
font-family: var(--sps-body);
}
.sps-ph__label {
font: 500 11px/1 var(--sps-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sps-ph__hint {
max-width: 28ch;
font: 500 13px/1.35 var(--sps-body);
}
.sps-ph__spec {
font: 400 12px/1.2 var(--sps-body);
}
/* Narrow phones: the slot gets short, so shed the label, then the spec.
The hint always stays, and the aria-label carries it too. */
.sps__media {
container-type: inline-size;
}
@container (max-width: 300px) {
.sps-ph {
gap: 4px;
padding: 10px;
}
.sps-ph__label {
display: none;
}
.sps-ph__hint {
font-size: 12px;
}
}
@container (max-width: 260px) {
.sps-ph__spec {
display: none;
}
}
.sps__copy {
display: flex;
flex-direction: column;
gap: 12px;
}
.sps__step-title {
margin: 0;
font: 400 24px/1 var(--sps-display);
letter-spacing: -0.02em;
text-transform: uppercase;
}
.sps__step-text {
margin: 0;
color: var(--sps-ink-soft);
font: 400 16px/1.6 var(--sps-body);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.sps {
--sps-badge: 50px;
padding: 100px 30px;
}
.sps--flush {
padding-top: 0;
}
.sps__inner {
gap: 56px;
}
.sps__title-group {
gap: 40px;
}
.sps__heading {
font-size: 40px;
}
.sps__list {
gap: 54px;
}
.sps__step {
gap: 36px;
}
.sps__badge {
font-size: 24px;
}
.sps__body {
gap: 30px;
}
.sps__step-title {
font-size: 32px;
}
}
/* ---- desktop: sticky intro beside the steps ---- */
@media (min-width: 1024px) {
.sps {
padding: 120px 30px;
}
.sps--flush {
padding-top: 0;
}
.sps__inner {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
}
.sps__intro {
position: sticky;
top: var(--sps-top);
flex: 0 1 394px;
}
.sps__title-group {
gap: 60px;
}
.sps__heading {
font-size: 48px;
}
.sps__steps {
flex: 0 1 543px;
}
}
/*
* The fill. The track owns a view timeline whose viewport is inset to a
* band around the middle of the screen, so the fill's leading edge follows
* roughly the middle of the viewport: empty while the list is below it,
* full once the list has passed it.
*/
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
[data-animate] .sps__track {
view-timeline-name: --sps-line;
view-timeline-axis: block;
view-timeline-inset: 40% 50%;
}
[data-animate] .sps__fill {
animation: sps-fill linear both;
animation-timeline: --sps-line;
animation-range: cover 0% cover 100%;
}
}
}
@keyframes sps-fill {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.sps__pill,
.sps__pill-icon {
transition: none;
}
.sps__pill:hover .sps__pill-icon {
transform: none;
}
}
</style>
===== END src/components/StickyProcessSteps.astro =====
===== FILE (supporting): src/components/StickyProcessSteps.md =====
# Sticky process steps
A process section. On the left is a sticky block with an eyebrow, a display
heading and a pill link. On the right are numbered steps, each an image with
a title and a line of text. A vertical line runs through the step numbers
and fills as the section scrolls past the middle of the screen.
Catalog page: `/components/sticky-process-steps`
## Purpose
"How it works" for a service with three to five steps. The sticky heading
keeps the question in view while the visitor reads the answer, and the
filling line shows how far through it they are.
## Files required
**`StickyProcessSteps.astro`**: the whole component. No imports, no
packages, no script and no global stylesheet. It reads `--lx-*` tokens with
fallbacks.
There are no image files. Step photos show labelled placeholders until you
pass a `src` (see Images below). There is no default content either: pass
`steps`, or nothing renders.
`StickyProcessSteps.md`: this file.
## Basic usage
```astro
<StickyProcessSteps
heading="How a shipment works"
ctaHref="/contact"
steps={[
{ title: "Request a rate", text: "Send the lane and the date.", image: { src: "/img/desk.jpg" } },
{ title: "Book pickup", text: "Choose a window.", image: { src: "/img/van.jpg" } },
]}
/>
```
## Props
| Prop | Type | Default | Notes |
| :--- | :--- | :--- | :--- |
| `id` | `string` | none | Applied to the `<section>`. |
| `eyebrow` | `string` | `"Process"` | Outlined pill. `""` drops it. |
| `heading` | `string` | `""` | Empty drops the heading. |
| `headingLevel` | `"h2" \| "h3"` | `"h2"` | Step titles use the next level down. |
| `ctaLabel` / `ctaHref` | `string` | `""` / `""` | Outline pill with a navy arrow disc. Drawn only when both are set. |
| `steps` | `ProcessStep[]` | `[]` | `{ title, text?, image? }`. `image` is `{ src?, alt?, hint? }` or `null` (no image). A step with no `title` is dropped. No steps, nothing renders. |
No demo content ships as a default: the catalog preview passes its steps,
heading and link as props. With no steps the component renders nothing —
the `"Process"` eyebrow alone never makes it render.
| `fill` | `"accent" \| "navy"` | `"navy"` | Colour of the progress fill. Navy is what the reference measures; `"accent"` fills it orange. |
| `animate` | `boolean` | `true` | `false` draws the line full and still. |
| `flushTop` | `boolean` | `false` | Removes the top padding, as the reference has. |
| `fontStylesheet` | `string` | Google Fonts URL | `""` if the page already loads the fonts. |
## Images
With no `src`, each step image renders a striped placeholder that shows
`IMAGE`, the step's `hint` if you passed one, and the spec line, with
`role="img"` and an `aria-label` of "Image placeholder: {hint}" (just
"Image placeholder" with no hint). There is no default hint.
| Slot | Ratio · size |
| :--- | :--- |
| Every step `image` | ~2:1 · 1400 × 720 px |
The catalog preview passes a hint per step describing the photo it wants.
- The slot is 457 × 235 at desktop (ratio 1.94) and keeps that ratio at
every width.
- On very narrow phones the placeholder drops first its `IMAGE` label
(slot under 300px wide) and then its spec line (under 260px), so the hint
still fits.
## Behaviour
- **Sticky block**: from 1024px, the left block is `position: sticky; top:
100px` beside the steps. Below 1024px it sits above them and does not
stick.
- **The line**: a 1px hairline track runs from the first number to the
bottom of the list, behind the white number badges. The fill inside it
scales from 0 to 1 on the track's own view timeline. The timeline's
viewport is inset to a band near the middle of the screen, so the fill's
leading edge roughly follows the middle of the viewport.
### Why CSS and not a script
The fill is one transform tied to one element's position in the viewport.
A CSS scroll-driven animation does exactly that on the compositor, with no
listeners, no `requestAnimationFrame` loop and no teardown. That means the
component can never leak a handler across an Astro client-side navigation,
and nothing in it can scroll the window. The cost is browser support: in a
browser without `animation-timeline` (Firefox at the time of writing), the
line is simply drawn full. That is the finished state, it looks
intentional, and every step is still readable. A script fallback would add
about 40 lines of lifecycle code to make a decorative line move for a
minority of visitors, so there isn't one.
## Accessibility
- The steps are an `<ol>`. The "01…" badges are `aria-hidden` because the
list already numbers them.
- Step images are decorative by default (the title carries the meaning).
- The pill link has a visible `:focus-visible` outline and is 50px tall.
- `prefers-reduced-motion: reduce` draws the line full with no animation
and turns off the arrow nudge on hover.
## Integration notes
- In the reference, this section follows one that supplies the gap above
it, so it has no top padding. Pass `flushTop` to reproduce that inside a
page. The default keeps 120px so the component stands alone.
- Sticky breaks under an ancestor with `overflow: hidden | auto | scroll`.
`overflow: clip` is fine.
## Approximations
- **Fill colour**: navy by default, as the reference measures
(`rgb(10,37,64)`). `fill="accent"` is an addition.
- **Fill timing**: the reference advances the fill using four invisible
per-step trigger blocks. Here it is one continuous animation across the
list, with its leading edge near mid-viewport. The effect is close, but
it is not step-quantised.
- **Badge states**: one reference screenshot shows three badges greyed out
and the fourth white. The capture could not show what triggers this, so
it is not reproduced. Every badge stays white with an accent numeral.
- **Phone sizes** (40px badges, 24px step titles, 20px gap) come from the
390 screenshot. The phone dump has only the outer boxes.
- **Tablet (810–1023px)** is interpolated: single column, with the desktop
step metrics.
===== END src/components/StickyProcessSteps.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to 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.