Component library
Back to catalog

Process timeline

A numbered vertical timeline whose rail fills as you scroll, beside a sticky list of principles.

sectioneditorialtimeline

Open preview in a new tab

Use this component

Three ways to get Process timeline 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 Process timeline from my Astro Component Library to this Astro project.

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

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: "Process timeline"
Library page: https://astro.baysixmedia.com/components/process-timeline/
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/ProcessTimeline.astro =====
---
/**
 * ProcessTimeline — a numbered vertical timeline with a scroll-driven progress
 * line, beside an optional sticky list of principles.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages:
 * drop it into any Astro project and render it.
 *
 * Two rails share the same 1px column: a faint full-height track, and an ink
 * fill scaled from the top that follows the scroll position. The dots sit on
 * that column, so the fill reads as progress through the steps.
 *
 * Measured behaviour:
 *   split         ~64.5/35.5 at >=1024, single column below
 *   column gap    128px at >=1024, 64px stacked
 *   rail          1px; dot 21px, 1px ring, centred on the rail at every width
 *   step rhythm   48px bottom padding, 48px indent (<768) / 64px (>=768)
 *   progress      scaleY = (0.7 x viewport height - column top) / column height
 *   dot hover     ring fills with ink over 500ms
 *   reveal        900ms mask-up per step, 70ms to the body; principles cascade
 *                 at 90ms each
 *   easing        cubic-bezier(0.16, 1, 0.3, 1) for reveals
 *
 * Several instances can share a page: the reveal observer and the progress
 * updater are both built per root and close over that root's own elements.
 */

export interface ProcessStep {
	/** Used for the step's element id. Falls back to no id. */
	id?: string;
	/** Overrides the generated "Step 01" label for this step. */
	label?: string;
	/** The step name, set in the large display size. */
	title: string;
	/** Supporting paragraph under the title. Optional. */
	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 timeline steps. Five reproduce the tested rhythm; any count works. */
	steps?: ProcessStep[];
	/** Short lines beside the timeline. Empty array or omitted hides the column. */
	principles?: string[];
	/** Heading over the principles list. */
	principlesTitle?: string;
	/** Word before each step number. `""` gives a bare "01". */
	stepLabelPrefix?: string;
	/** First number in the sequence, for a timeline continuing another. */
	numberFrom?: number;
	/** grid-template-columns for the desktop split. */
	split?: string;
	/** Stick the principles column while the steps scroll. Desktop only either way. */
	sticky?: boolean;
	/** Offset from the top of the viewport when stuck. */
	stickyTop?: string;
	/** Fill the rail as the section scrolls past. */
	progress?: boolean;
	/** Fraction of the viewport height the fill tracks. `0.7` is 70% down. */
	progressAnchor?: number;
	/** Section background. */
	surface?: string;
	/** Text colour. Muted tones and the rail are derived from it. */
	ink?: string;
	/** Fill inside an unreached dot. Should match the surface behind it. */
	dotFill?: string;
	/** Animate steps 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 {
	eyebrow = "",
	heading = "",
	intro = "",
	steps = [],
	principles = [],
	principlesTitle = "House rules",
	stepLabelPrefix = "Step",
	numberFrom = 1,
	split = "64.5fr 35.5fr",
	sticky = true,
	stickyTop = "96px",
	progress = true,
	progressAnchor = 0.7,
	surface = "#ffffff",
	ink = "#14161a",
	dotFill = "#ffffff",
	reveal = true,
	headingLevel = "h2",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
/* A step needs a title; one without is dropped rather than drawn as a bare
   dot. No steps, no section — the heading and principles alone are not a
   timeline. */
const list = steps.filter((step) => typeof step?.title === "string" && step.title.trim() !== "");
const lines = principles.filter((line) => typeof line === "string" && line.trim() !== "");
const hasPrinciples = lines.length > 0;
const hasIntro = Boolean(eyebrow || heading || intro);
const stepLabel = (step: ProcessStep, i: number) => {
	if (step.label !== undefined) return step.label;
	const n = String(i + numberFrom).padStart(2, "0");
	return stepLabelPrefix ? `${stepLabelPrefix} ${n}` : n;
};
---

{list.length > 0 && (
<section
	class:list={["timeline", className]}
	id={id}
	data-process-timeline
	data-reveal={reveal ? "" : undefined}
	data-progress={progress ? "" : undefined}
	data-anchor={String(progressAnchor)}
	data-sticky={sticky ? "" : undefined}
	style={`--pt-surface:${surface};--pt-ink:${ink};--pt-dot-fill:${dotFill};--pt-sticky-top:${stickyTop};--pt-split:${split};`}
>
	<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:list={["body", { solo: !hasPrinciples }]}>
			<div class="track-col" data-pt-col>
				{/* The faint rail, and the ink fill that grows over it. */}
				<span class="rail" aria-hidden="true"></span>
				{progress && <span class="rail fill" aria-hidden="true" data-pt-fill />}

				<ol class="steps">
					{
						list.map((step, i) => (
							<li class="step" id={step.id} data-group>
								<span class="dot" aria-hidden="true" />
								<span class="step-label">{stepLabel(step, i)}</span>
								<h3 class="step-title" data-r-mask style="--d:0ms">
									<span>{step.title}</span>
								</h3>
								{step.body && (
									<p class="step-body" data-r-up style="--d:70ms">
										{step.body}
									</p>
								)}
							</li>
						))
					}
				</ol>
			</div>

			{
				hasPrinciples && (
					<aside class="principles" data-group>
						{principlesTitle && (
							<h3 class="principles-title" data-r-mask style="--d:0ms">
								<span>{principlesTitle}</span>
							</h3>
						)}
						<ul class="principles-list">
							{lines.map((line, i) => (
								<li class="principle" data-r-up style={`--d:${(i + 1) * 90}ms`}>
									<span class="dash" aria-hidden="true">
										—
									</span>
									<span>{line}</span>
								</li>
							))}
						</ul>
					</aside>
				)
			}
		</div>
	</div>
</section>
)}

<style>
	.timeline {
		/* Every tone is derived from --pt-ink, so one override re-tints the
		   whole section instead of leaving half of it behind. */
		--pt-muted: color-mix(in srgb, var(--pt-ink) 52%, transparent);
		--pt-detail: color-mix(in srgb, var(--pt-ink) 78%, transparent);
		--pt-hairline: color-mix(in srgb, var(--pt-ink) 13%, transparent);

		--pt-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. */
		--pt-display: clamp(1.375rem, 1.05rem + 1.6vw, 2.5rem);
		/* The step name is the largest thing here: 27px at 390, 56px at 1440. */
		--pt-step: clamp(1.6875rem, 0.55rem + 3.3vw, 3.5rem);
		--pt-body: 1rem;

		/* Rail geometry. The rail is placed from the dot so the two can never
		   drift apart: whatever the dot's offset, the line runs through its
		   centre. */
		--pt-dot: 21px;
		--pt-dot-left: 0px;
		--pt-rail: calc(var(--pt-dot-left) + var(--pt-dot) / 2);
		--pt-indent: 48px;

		--pt-gutter: clamp(20px, 5vw, 80px);
		--pt-maxw: 1280px;
		--pt-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		display: block;
		padding: clamp(64px, 9vw, 128px) var(--pt-gutter);
		background: var(--pt-surface);
		color: var(--pt-ink);
		font-family: var(--pt-font);
	}

	.timeline :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--pt-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(--pt-muted);
	}

	.kicker-text {
		flex-shrink: 0;
	}

	.kicker-rule {
		flex: 1;
		height: 1px;
		background: var(--pt-hairline);
		transform-origin: left center;
	}

	.heading {
		margin: 0;
		font-size: var(--pt-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(--pt-body);
		line-height: 1.6;
		color: var(--pt-detail);
	}

	/* ---------- body split ---------- */

	.body {
		display: grid;
		grid-template-columns: 1fr;
		gap: 64px;
	}

	/* ---------- the rail ---------- */

	.track-col {
		position: relative;
		min-width: 0;
	}

	.rail {
		position: absolute;
		top: 0;
		left: var(--pt-rail);
		width: 1px;
		height: 100%;
		background: var(--pt-hairline);
	}

	/*
	 * The fill sits on the same line as the rail and is scaled from its top
	 * edge by the script. It carries no transition on purpose: it tracks the
	 * scroll position directly, and easing it would make it lag the scroll.
	 */
	.fill {
		background: var(--pt-ink);
		transform: scaleY(0);
		transform-origin: top center;
		will-change: transform;
	}

	/* ---------- steps ---------- */

	.steps {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
	}

	.step {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 8px;
		padding-left: var(--pt-indent);
		padding-bottom: 48px;
	}

	/* The rail should not outrun the last step's text. */
	.step:last-child {
		padding-bottom: 0;
	}

	.dot {
		position: absolute;
		top: 0.6em;
		left: var(--pt-dot-left);
		width: var(--pt-dot);
		height: var(--pt-dot);
		border: 1px solid var(--pt-ink);
		border-radius: 50%;
		background: var(--pt-dot-fill);
		transition: background-color 500ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.step-label {
		font-size: var(--pt-body);
		font-weight: 500;
		letter-spacing: 0.24em;
		font-variant-numeric: tabular-nums;
		color: var(--pt-muted);
	}

	.step-title {
		margin: 0;
		font-size: var(--pt-step);
		line-height: 0.92;
		font-weight: 600;
		letter-spacing: -0.045em;
	}

	.step-body {
		margin: 0;
		/* A narrow measure under a very large title, matching the reference. */
		max-width: 29rem;
		font-size: var(--pt-body);
		line-height: 1.6;
		color: var(--pt-detail);
		text-wrap: pretty;
	}

	/* ---------- principles ---------- */

	/*
	 * Laid out with a flex gap rather than a margin on the title: the reveal
	 * mask below sets a negative margin-bottom on every [data-r-mask], which
	 * would otherwise cancel a margin declared here.
	 */
	.principles {
		height: fit-content;
		display: flex;
		flex-direction: column;
		gap: 40px;
		border-top: 1px solid var(--pt-ink);
		padding-top: 24px;
	}

	.principles-title {
		margin: 0;
		font-size: var(--pt-body);
		font-weight: 500;
		letter-spacing: 0.24em;
		color: var(--pt-muted);
	}

	.principles-list {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 16px;
	}

	.principle {
		display: flex;
		gap: 8px;
		font-size: var(--pt-body);
		line-height: 1.8;
		text-wrap: pretty;
	}

	.dash {
		flex-shrink: 0;
		color: var(--pt-muted);
	}

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

	.timeline[data-reveal] [data-r-mask] {
		overflow: hidden;
		/* Give descenders the room the clip would otherwise cut. */
		padding-bottom: 0.12em;
		margin-bottom: -0.12em;
	}

	.timeline[data-reveal] [data-r-mask] > span {
		display: block;
		transform: translateY(110%);
		opacity: 0;
		transition:
			transform 900ms var(--pt-ease) var(--d, 0ms),
			opacity 900ms var(--pt-ease) var(--d, 0ms);
	}

	.timeline[data-reveal] [data-r-up] {
		transform: translateY(28px);
		opacity: 0;
		transition:
			transform 900ms var(--pt-ease) var(--d, 0ms),
			opacity 900ms var(--pt-ease) var(--d, 0ms);
	}

	.timeline[data-reveal] [data-r-rule] {
		transform: scaleX(0);
		transition: transform 1100ms var(--pt-ease);
	}

	.timeline[data-reveal] [data-in] :is([data-r-mask] > span, [data-r-up]) {
		transform: none;
		opacity: 1;
	}

	.timeline[data-reveal] [data-in] [data-r-rule] {
		transform: scaleX(1);
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.timeline {
			--pt-dot-left: 4px;
			--pt-indent: 64px;
		}
	}

	@media (min-width: 1024px) {
		.body {
			grid-template-columns: var(--pt-split);
			gap: 128px;
		}

		.body.solo {
			grid-template-columns: 1fr;
		}

		.timeline[data-sticky] .principles {
			position: sticky;
			top: var(--pt-sticky-top);
		}
	}

	/* The dot fill is a pointer affordance. On a touch screen :hover sticks
	   after the finger lifts, which leaves a dot filled for no reason. */
	@media (hover: hover) and (pointer: fine) {
		.step:hover .dot {
			background: var(--pt-ink);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.timeline[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
			transform: none;
			opacity: 1;
			transition: none;
		}

		.timeline[data-reveal] [data-r-rule] {
			transform: scaleX(1);
			transition: none;
		}

		.dot {
			transition: none;
		}
	}
</style>

<noscript>
	<style>
		.timeline[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
			transform: none;
			opacity: 1;
		}

		.timeline[data-reveal] [data-r-rule] {
			transform: scaleX(1);
		}
	</style>
</noscript>

<script>
	/*
	 * Two independent behaviours, both built per instance so that several
	 * timelines on a page never share state: the entrance reveal, and the
	 * scroll-driven rail fill.
	 */

	/* ---- entrance reveal ---- */

	const setupReveal = (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 step
		   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 step that has arrived stays arrived. */
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The bottom margin sets the trigger point: a step reveals once it
			 * is 8% into the viewport.
			 *
			 * The very large top margin is what makes a jump safe. An observer
			 * only reports a *change* in intersection, so a step that goes from
			 * below the viewport to above it in one jump — an anchor link, a
			 * restored scroll position, a flick on a touchpad — would report 0
			 * both times, never fire, and stay invisible for good. Extending
			 * the root far upwards means "already scrolled past" still counts
			 * as intersecting, so those steps reveal instead of being stranded.
			 */
			{ rootMargin: "10000px 0px -8% 0px", threshold: 0 },
		);

		groups.forEach((group) => observer.observe(group));
	};

	/* ---- scroll-driven rail fill ---- */

	/**
	 * Returns this instance's update function, closed over its own column and
	 * fill, or null if the instance has progress turned off.
	 *
	 * The fill reaches a point on the rail when that point crosses a line
	 * `anchor` of the way down the viewport — 70% by default. It deliberately
	 * carries no CSS transition: the value is recomputed per frame from the
	 * scroll position, and easing it would only make it trail the scroll.
	 */
	const makeProgress = (root: HTMLElement) => {
		if (!root.hasAttribute("data-progress")) return null;

		const column = root.querySelector<HTMLElement>("[data-pt-col]");
		const fill = root.querySelector<HTMLElement>("[data-pt-fill]");
		if (!column || !fill) return null;

		const parsed = Number(root.dataset.anchor);
		const anchor = Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : 0.7;

		return () => {
			const box = column.getBoundingClientRect();
			if (box.height === 0) return;
			const line = window.innerHeight * anchor;
			const ratio = (line - box.top) / box.height;
			const clamped = ratio < 0 ? 0 : ratio > 1 ? 1 : ratio;
			fill.style.transform = `scaleY(${clamped})`;
		};
	};

	const roots = [...document.querySelectorAll<HTMLElement>("[data-process-timeline]")];
	roots.forEach(setupReveal);

	const updates = roots.map(makeProgress).filter((fn): fn is () => void => fn !== null);

	if (updates.length > 0) {
		/* One rAF-throttled listener drives every instance on the page, so ten
		   timelines still cost one handler and one frame. */
		let queued = false;
		const run = () => {
			queued = false;
			for (const update of updates) update();
		};
		const schedule = () => {
			if (queued) return;
			queued = true;
			requestAnimationFrame(run);
		};

		window.addEventListener("scroll", schedule, { passive: true });
		window.addEventListener("resize", schedule);
		run();
	}
</script>

===== END src/components/ProcessTimeline.astro =====

===== FILE (supporting): src/components/ProcessTimeline.md =====
# Process timeline

A numbered vertical timeline whose rail fills as the section scrolls past,
beside an optional list of principles that sticks while the steps move. Dots
sit on the rail and fill with ink under the pointer; each step rises into place
as it enters the viewport.

Catalog page: `/components/process-timeline`

## Copying it into another Astro project

Copy **`ProcessTimeline.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 `--pt-*` 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 ProcessTimeline from "../components/ProcessTimeline.astro";
---

<ProcessTimeline
  eyebrow="03 — Process"
  heading="Fewer parts, decided once, written down."
  steps={[
    { title: "Collect", body: "Every existing variant comes out of the codebase." },
    { title: "Reduce", body: "Requirements stay, drift goes." },
  ]}
  principles={["Delete a variant for every part you publish"]}
/>
```

Every prop is optional, but content has no default: with no `steps` (or none
with a title) the component renders nothing. The catalog page passes the
five-step demo and the principles column as props.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `steps` | `ProcessStep[]` | `[]` | The timeline steps. Any count works. A step with no title is dropped; none renders nothing. |
| `principles` | `string[]` | `[]` | Lines beside the timeline. `[]` drops the whole column. |
| `principlesTitle` | `string` | `"House rules"` | Heading over the principles list. |
| `eyebrow` | `string` | `""` | Small tracked label above the heading. `""` hides it. |
| `heading` | `string` | `""` | Section heading. `""` hides it. |
| `intro` | `string` | `""` | Paragraph under the heading. `""` hides it. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is the wrong level. |
| `stepLabelPrefix` | `string` | `"Step"` | Word before the number. `""` gives a bare `01`. |
| `numberFrom` | `number` | `1` | First number in the sequence. |
| `split` | `string` | `"64.5fr 35.5fr"` | `grid-template-columns` for the desktop split. |
| `sticky` | `boolean` | `true` | Stick the principles column. Desktop only either way. |
| `stickyTop` | `string` | `"96px"` | Offset from the top of the viewport when stuck. |
| `progress` | `boolean` | `true` | Fill the rail as the section scrolls past. |
| `progressAnchor` | `number` | `0.7` | Fraction of viewport height the fill tracks. |
| `surface` | `string` | `"#ffffff"` | Section background. |
| `ink` | `string` | `"#14161a"` | Text colour. Muted tones and the rail derive from it. |
| `dotFill` | `string` | `"#ffffff"` | Fill inside an unreached dot. Match it to the surface. |
| `reveal` | `boolean` | `true` | Animate steps in as they enter the viewport. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. |

### `ProcessStep`

```ts
interface ProcessStep {
  id?: string;     // element id for the step; omit for none
  label?: string;  // overrides the generated "Step 01"
  title: string;   // the step name, in the large display size
  body?: string;   // supporting paragraph
}
```

`ink` and `surface` re-tint the section; the muted, detail and rail tones are
all `color-mix()` derivations of `--pt-ink`, so one override moves them
together. `dotFill` is separate because a dot is a hole punched in the rail —
it should match whatever sits behind it, which is usually `surface`.

## Layout

| | ≥1024 | 768–1023 | <768 |
| :--- | :--- | :--- | :--- |
| Columns | `64.5fr 35.5fr` | single | single |
| Column gap | 128px | 64px | 64px |
| Principles | sticky at `96px` | static, below the steps | static, below the steps |
| Step indent | 64px | 64px | 48px |
| Dot offset | 4px | 4px | 0 |
| Heading | 40px | fluid | 22px |
| Step title | ~51px | ~36px | 27px |
| Section gutter | `clamp(20px, 5vw, 80px)` | ← | ← |

The dot is 21px with a 1px ring, and the rail is positioned *from* the dot:

```css
--pt-rail: calc(var(--pt-dot-left) + var(--pt-dot) / 2);
```

So the line runs through the dot's centre at every width by construction. Move
the dot or resize it and the rail follows; there is no second value to keep in
sync.

## Behaviour

- **Scroll-driven rail.** Two rails share the same line: a faint full-height
  track, and an ink fill scaled from the top. The fill reaches a point on the
  rail when that point crosses a line `progressAnchor` of the way down the
  viewport — 70% by default. It carries **no CSS transition** on purpose: the
  value is recomputed per frame from the scroll position, and easing it would
  only make it trail the scroll.
- **Dot hover:** the ring fills with ink over `500ms`. Gated behind
  `(hover: hover) and (pointer: fine)`, because on a touch screen `:hover`
  sticks after the finger lifts and would leave a dot filled for no reason.
- **Reveal:** each step, the header and the principles block are observed
  independently. The title rises out from behind a clipping mask over `900ms`
  and the body follows `70ms` later; the principles cascade at `90ms` each.
  Reveal is one-way — a step that has arrived stays arrived.
- **Reduced motion:** `prefers-reduced-motion: reduce` shows everything
  immediately and removes the dot transition. The rail fill is deliberately
  **kept**: it moves only in response to the reader's own scrolling, the way a
  scrollbar does, and removing it would drop a position cue rather than an
  animation.
- **Several per page:** the reveal observer and the progress updater are both
  built per root and close over that root's own elements. One rAF-throttled
  scroll listener drives every instance on the page, so ten timelines still
  cost one handler and one frame.

### Why the observer's root is extended upwards

The reveal observer uses:

```js
{ rootMargin: "10000px 0px -8% 0px", threshold: 0 }
```

The bottom margin sets the trigger point — a step reveals once it is 8% into
the viewport. The large **top** margin is what makes a jump safe.

An `IntersectionObserver` only reports a *change* in intersection. A step that
goes from below the viewport to above it in a single jump — an anchor link, a
restored scroll position, a flick on a trackpad — reads as `0` before and `0`
after, so no callback ever fires and the step stays invisible permanently.
Extending the root far upwards means "already scrolled past" still counts as
intersecting, so a jumped-over step reveals instead of being stranded.

### 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 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 step 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. `progress={false}`
likewise skips the fill element entirely, leaving only the faint track.

### The fill in an isolated preview

On the catalog's preview page the section *is* the whole document, so there is
nothing below it to scroll through and the rail stops a little short of full.
That is the formula behaving correctly with no trailing content, not a bug — on
a real page with anything underneath, the fill completes.

## Notes

The example copy (passed by the catalog, not built in) is written for this catalog and describes how a component
earns its place in a library. It is illustrative only — no real organisation,
product or quotation is involved, and nothing here carries anyone's branding.
Replace `steps`, `principles`, `heading` and `intro` with your own and the
design is unchanged.

===== END src/components/ProcessTimeline.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.

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
“Process timeline” from my Astro Component Library:
https://astro.baysixmedia.com/components/process-timeline/

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.

src/components/library/ProcessTimeline.astro

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

---
/**
 * ProcessTimeline — a numbered vertical timeline with a scroll-driven progress
 * line, beside an optional sticky list of principles.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages:
 * drop it into any Astro project and render it.
 *
 * Two rails share the same 1px column: a faint full-height track, and an ink
 * fill scaled from the top that follows the scroll position. The dots sit on
 * that column, so the fill reads as progress through the steps.
 *
 * Measured behaviour:
 *   split         ~64.5/35.5 at >=1024, single column below
 *   column gap    128px at >=1024, 64px stacked
 *   rail          1px; dot 21px, 1px ring, centred on the rail at every width
 *   step rhythm   48px bottom padding, 48px indent (<768) / 64px (>=768)
 *   progress      scaleY = (0.7 x viewport height - column top) / column height
 *   dot hover     ring fills with ink over 500ms
 *   reveal        900ms mask-up per step, 70ms to the body; principles cascade
 *                 at 90ms each
 *   easing        cubic-bezier(0.16, 1, 0.3, 1) for reveals
 *
 * Several instances can share a page: the reveal observer and the progress
 * updater are both built per root and close over that root's own elements.
 */

export interface ProcessStep {
	/** Used for the step's element id. Falls back to no id. */
	id?: string;
	/** Overrides the generated "Step 01" label for this step. */
	label?: string;
	/** The step name, set in the large display size. */
	title: string;
	/** Supporting paragraph under the title. Optional. */
	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 timeline steps. Five reproduce the tested rhythm; any count works. */
	steps?: ProcessStep[];
	/** Short lines beside the timeline. Empty array or omitted hides the column. */
	principles?: string[];
	/** Heading over the principles list. */
	principlesTitle?: string;
	/** Word before each step number. `""` gives a bare "01". */
	stepLabelPrefix?: string;
	/** First number in the sequence, for a timeline continuing another. */
	numberFrom?: number;
	/** grid-template-columns for the desktop split. */
	split?: string;
	/** Stick the principles column while the steps scroll. Desktop only either way. */
	sticky?: boolean;
	/** Offset from the top of the viewport when stuck. */
	stickyTop?: string;
	/** Fill the rail as the section scrolls past. */
	progress?: boolean;
	/** Fraction of the viewport height the fill tracks. `0.7` is 70% down. */
	progressAnchor?: number;
	/** Section background. */
	surface?: string;
	/** Text colour. Muted tones and the rail are derived from it. */
	ink?: string;
	/** Fill inside an unreached dot. Should match the surface behind it. */
	dotFill?: string;
	/** Animate steps 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 {
	eyebrow = "",
	heading = "",
	intro = "",
	steps = [],
	principles = [],
	principlesTitle = "House rules",
	stepLabelPrefix = "Step",
	numberFrom = 1,
	split = "64.5fr 35.5fr",
	sticky = true,
	stickyTop = "96px",
	progress = true,
	progressAnchor = 0.7,
	surface = "#ffffff",
	ink = "#14161a",
	dotFill = "#ffffff",
	reveal = true,
	headingLevel = "h2",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;
/* A step needs a title; one without is dropped rather than drawn as a bare
   dot. No steps, no section — the heading and principles alone are not a
   timeline. */
const list = steps.filter((step) => typeof step?.title === "string" && step.title.trim() !== "");
const lines = principles.filter((line) => typeof line === "string" && line.trim() !== "");
const hasPrinciples = lines.length > 0;
const hasIntro = Boolean(eyebrow || heading || intro);
const stepLabel = (step: ProcessStep, i: number) => {
	if (step.label !== undefined) return step.label;
	const n = String(i + numberFrom).padStart(2, "0");
	return stepLabelPrefix ? `${stepLabelPrefix} ${n}` : n;
};
---

{list.length > 0 && (
<section
	class:list={["timeline", className]}
	id={id}
	data-process-timeline
	data-reveal={reveal ? "" : undefined}
	data-progress={progress ? "" : undefined}
	data-anchor={String(progressAnchor)}
	data-sticky={sticky ? "" : undefined}
	style={`--pt-surface:${surface};--pt-ink:${ink};--pt-dot-fill:${dotFill};--pt-sticky-top:${stickyTop};--pt-split:${split};`}
>
	<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:list={["body", { solo: !hasPrinciples }]}>
			<div class="track-col" data-pt-col>
				{/* The faint rail, and the ink fill that grows over it. */}
				<span class="rail" aria-hidden="true"></span>
				{progress && <span class="rail fill" aria-hidden="true" data-pt-fill />}

				<ol class="steps">
					{
						list.map((step, i) => (
							<li class="step" id={step.id} data-group>
								<span class="dot" aria-hidden="true" />
								<span class="step-label">{stepLabel(step, i)}</span>
								<h3 class="step-title" data-r-mask style="--d:0ms">
									<span>{step.title}</span>
								</h3>
								{step.body && (
									<p class="step-body" data-r-up style="--d:70ms">
										{step.body}
									</p>
								)}
							</li>
						))
					}
				</ol>
			</div>

			{
				hasPrinciples && (
					<aside class="principles" data-group>
						{principlesTitle && (
							<h3 class="principles-title" data-r-mask style="--d:0ms">
								<span>{principlesTitle}</span>
							</h3>
						)}
						<ul class="principles-list">
							{lines.map((line, i) => (
								<li class="principle" data-r-up style={`--d:${(i + 1) * 90}ms`}>
									<span class="dash" aria-hidden="true">
										—
									</span>
									<span>{line}</span>
								</li>
							))}
						</ul>
					</aside>
				)
			}
		</div>
	</div>
</section>
)}

<style>
	.timeline {
		/* Every tone is derived from --pt-ink, so one override re-tints the
		   whole section instead of leaving half of it behind. */
		--pt-muted: color-mix(in srgb, var(--pt-ink) 52%, transparent);
		--pt-detail: color-mix(in srgb, var(--pt-ink) 78%, transparent);
		--pt-hairline: color-mix(in srgb, var(--pt-ink) 13%, transparent);

		--pt-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. */
		--pt-display: clamp(1.375rem, 1.05rem + 1.6vw, 2.5rem);
		/* The step name is the largest thing here: 27px at 390, 56px at 1440. */
		--pt-step: clamp(1.6875rem, 0.55rem + 3.3vw, 3.5rem);
		--pt-body: 1rem;

		/* Rail geometry. The rail is placed from the dot so the two can never
		   drift apart: whatever the dot's offset, the line runs through its
		   centre. */
		--pt-dot: 21px;
		--pt-dot-left: 0px;
		--pt-rail: calc(var(--pt-dot-left) + var(--pt-dot) / 2);
		--pt-indent: 48px;

		--pt-gutter: clamp(20px, 5vw, 80px);
		--pt-maxw: 1280px;
		--pt-ease: cubic-bezier(0.16, 1, 0.3, 1);

		box-sizing: border-box;
		display: block;
		padding: clamp(64px, 9vw, 128px) var(--pt-gutter);
		background: var(--pt-surface);
		color: var(--pt-ink);
		font-family: var(--pt-font);
	}

	.timeline :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.shell {
		max-width: var(--pt-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(--pt-muted);
	}

	.kicker-text {
		flex-shrink: 0;
	}

	.kicker-rule {
		flex: 1;
		height: 1px;
		background: var(--pt-hairline);
		transform-origin: left center;
	}

	.heading {
		margin: 0;
		font-size: var(--pt-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(--pt-body);
		line-height: 1.6;
		color: var(--pt-detail);
	}

	/* ---------- body split ---------- */

	.body {
		display: grid;
		grid-template-columns: 1fr;
		gap: 64px;
	}

	/* ---------- the rail ---------- */

	.track-col {
		position: relative;
		min-width: 0;
	}

	.rail {
		position: absolute;
		top: 0;
		left: var(--pt-rail);
		width: 1px;
		height: 100%;
		background: var(--pt-hairline);
	}

	/*
	 * The fill sits on the same line as the rail and is scaled from its top
	 * edge by the script. It carries no transition on purpose: it tracks the
	 * scroll position directly, and easing it would make it lag the scroll.
	 */
	.fill {
		background: var(--pt-ink);
		transform: scaleY(0);
		transform-origin: top center;
		will-change: transform;
	}

	/* ---------- steps ---------- */

	.steps {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
	}

	.step {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 8px;
		padding-left: var(--pt-indent);
		padding-bottom: 48px;
	}

	/* The rail should not outrun the last step's text. */
	.step:last-child {
		padding-bottom: 0;
	}

	.dot {
		position: absolute;
		top: 0.6em;
		left: var(--pt-dot-left);
		width: var(--pt-dot);
		height: var(--pt-dot);
		border: 1px solid var(--pt-ink);
		border-radius: 50%;
		background: var(--pt-dot-fill);
		transition: background-color 500ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.step-label {
		font-size: var(--pt-body);
		font-weight: 500;
		letter-spacing: 0.24em;
		font-variant-numeric: tabular-nums;
		color: var(--pt-muted);
	}

	.step-title {
		margin: 0;
		font-size: var(--pt-step);
		line-height: 0.92;
		font-weight: 600;
		letter-spacing: -0.045em;
	}

	.step-body {
		margin: 0;
		/* A narrow measure under a very large title, matching the reference. */
		max-width: 29rem;
		font-size: var(--pt-body);
		line-height: 1.6;
		color: var(--pt-detail);
		text-wrap: pretty;
	}

	/* ---------- principles ---------- */

	/*
	 * Laid out with a flex gap rather than a margin on the title: the reveal
	 * mask below sets a negative margin-bottom on every [data-r-mask], which
	 * would otherwise cancel a margin declared here.
	 */
	.principles {
		height: fit-content;
		display: flex;
		flex-direction: column;
		gap: 40px;
		border-top: 1px solid var(--pt-ink);
		padding-top: 24px;
	}

	.principles-title {
		margin: 0;
		font-size: var(--pt-body);
		font-weight: 500;
		letter-spacing: 0.24em;
		color: var(--pt-muted);
	}

	.principles-list {
		list-style: none;
		margin: 0;
		padding: 0;
		display: flex;
		flex-direction: column;
		gap: 16px;
	}

	.principle {
		display: flex;
		gap: 8px;
		font-size: var(--pt-body);
		line-height: 1.8;
		text-wrap: pretty;
	}

	.dash {
		flex-shrink: 0;
		color: var(--pt-muted);
	}

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

	.timeline[data-reveal] [data-r-mask] {
		overflow: hidden;
		/* Give descenders the room the clip would otherwise cut. */
		padding-bottom: 0.12em;
		margin-bottom: -0.12em;
	}

	.timeline[data-reveal] [data-r-mask] > span {
		display: block;
		transform: translateY(110%);
		opacity: 0;
		transition:
			transform 900ms var(--pt-ease) var(--d, 0ms),
			opacity 900ms var(--pt-ease) var(--d, 0ms);
	}

	.timeline[data-reveal] [data-r-up] {
		transform: translateY(28px);
		opacity: 0;
		transition:
			transform 900ms var(--pt-ease) var(--d, 0ms),
			opacity 900ms var(--pt-ease) var(--d, 0ms);
	}

	.timeline[data-reveal] [data-r-rule] {
		transform: scaleX(0);
		transition: transform 1100ms var(--pt-ease);
	}

	.timeline[data-reveal] [data-in] :is([data-r-mask] > span, [data-r-up]) {
		transform: none;
		opacity: 1;
	}

	.timeline[data-reveal] [data-in] [data-r-rule] {
		transform: scaleX(1);
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 768px) {
		.timeline {
			--pt-dot-left: 4px;
			--pt-indent: 64px;
		}
	}

	@media (min-width: 1024px) {
		.body {
			grid-template-columns: var(--pt-split);
			gap: 128px;
		}

		.body.solo {
			grid-template-columns: 1fr;
		}

		.timeline[data-sticky] .principles {
			position: sticky;
			top: var(--pt-sticky-top);
		}
	}

	/* The dot fill is a pointer affordance. On a touch screen :hover sticks
	   after the finger lifts, which leaves a dot filled for no reason. */
	@media (hover: hover) and (pointer: fine) {
		.step:hover .dot {
			background: var(--pt-ink);
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.timeline[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
			transform: none;
			opacity: 1;
			transition: none;
		}

		.timeline[data-reveal] [data-r-rule] {
			transform: scaleX(1);
			transition: none;
		}

		.dot {
			transition: none;
		}
	}
</style>

<noscript>
	<style>
		.timeline[data-reveal] :is([data-r-mask] > span, [data-r-up]) {
			transform: none;
			opacity: 1;
		}

		.timeline[data-reveal] [data-r-rule] {
			transform: scaleX(1);
		}
	</style>
</noscript>

<script>
	/*
	 * Two independent behaviours, both built per instance so that several
	 * timelines on a page never share state: the entrance reveal, and the
	 * scroll-driven rail fill.
	 */

	/* ---- entrance reveal ---- */

	const setupReveal = (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 step
		   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 step that has arrived stays arrived. */
					observer.unobserve(entry.target);
				}
			},
			/*
			 * The bottom margin sets the trigger point: a step reveals once it
			 * is 8% into the viewport.
			 *
			 * The very large top margin is what makes a jump safe. An observer
			 * only reports a *change* in intersection, so a step that goes from
			 * below the viewport to above it in one jump — an anchor link, a
			 * restored scroll position, a flick on a touchpad — would report 0
			 * both times, never fire, and stay invisible for good. Extending
			 * the root far upwards means "already scrolled past" still counts
			 * as intersecting, so those steps reveal instead of being stranded.
			 */
			{ rootMargin: "10000px 0px -8% 0px", threshold: 0 },
		);

		groups.forEach((group) => observer.observe(group));
	};

	/* ---- scroll-driven rail fill ---- */

	/**
	 * Returns this instance's update function, closed over its own column and
	 * fill, or null if the instance has progress turned off.
	 *
	 * The fill reaches a point on the rail when that point crosses a line
	 * `anchor` of the way down the viewport — 70% by default. It deliberately
	 * carries no CSS transition: the value is recomputed per frame from the
	 * scroll position, and easing it would only make it trail the scroll.
	 */
	const makeProgress = (root: HTMLElement) => {
		if (!root.hasAttribute("data-progress")) return null;

		const column = root.querySelector<HTMLElement>("[data-pt-col]");
		const fill = root.querySelector<HTMLElement>("[data-pt-fill]");
		if (!column || !fill) return null;

		const parsed = Number(root.dataset.anchor);
		const anchor = Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : 0.7;

		return () => {
			const box = column.getBoundingClientRect();
			if (box.height === 0) return;
			const line = window.innerHeight * anchor;
			const ratio = (line - box.top) / box.height;
			const clamped = ratio < 0 ? 0 : ratio > 1 ? 1 : ratio;
			fill.style.transform = `scaleY(${clamped})`;
		};
	};

	const roots = [...document.querySelectorAll<HTMLElement>("[data-process-timeline]")];
	roots.forEach(setupReveal);

	const updates = roots.map(makeProgress).filter((fn): fn is () => void => fn !== null);

	if (updates.length > 0) {
		/* One rAF-throttled listener drives every instance on the page, so ten
		   timelines still cost one handler and one frame. */
		let queued = false;
		const run = () => {
			queued = false;
			for (const update of updates) update();
		};
		const schedule = () => {
			if (queued) return;
			queued = true;
			requestAnimationFrame(run);
		};

		window.addEventListener("scroll", schedule, { passive: true });
		window.addEventListener("resize", schedule);
		run();
	}
</script>