Component library
Back to catalog

Sticky process steps

A sticky heading block beside numbered process steps, each an image, title and line of text, threaded on a vertical line that fills as the section scrolls past. CSS scroll-driven fill; drawn full without support or under reduced motion.

processstepstimelinestickyscroll-drivenstatic

Open preview in a new tab

Use this component

Three ways to get Sticky process steps into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Sticky process steps from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/sticky-process-steps/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

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.

Install kit

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.

Component reference

A fragment for 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 branding and content open. It does not carry the code.

Read it first
“Sticky process steps” from my Astro Component Library:
https://astro.baysixmedia.com/components/sticky-process-steps/

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.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixture

src/components/library/StickyProcessSteps.astro

one fileNo imports, no companion files, no packages. Copy StickyProcessSteps.astro into any Astro project's components folder and render it.

---
/**
 * 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>