Component librarylocal catalog
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, all pre-filled with this component's name and library page.

Start here

Component reference

Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.

Read it first
“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.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Process timeline
Library page: https://astro.baysixmedia.com/components/process-timeline

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Process timeline from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/process-timeline

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

src/components/library/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 defaultSteps: ProcessStep[] = [
	{
		title: "Collect",
		body: "Every existing variant of the pattern comes out of the codebase and goes side by side, so the real differences are visible before anything is decided.",
	},
	{
		title: "Reduce",
		body: "We sort the differences that are genuine requirements from the ones that are drift, and throw the drift away.",
	},
	{
		title: "Draft",
		body: "One version gets built, with typed props, scoped styles and the states the old variants implied but never handled.",
	},
	{
		title: "Prove",
		body: "It renders at three widths, takes a keyboard, and survives motion being turned off — before anything else is allowed to depend on it.",
	},
	{
		title: "Publish",
		body: "It gets registered, what it assumes about its host gets written down, and the variants it replaces get deleted.",
	},
];

const defaultPrinciples: string[] = [
	"Delete a variant for every part you publish",
	"A component that needs a page to explain it is too big",
	"States and keyboard behaviour are requirements, not polish",
	"If it cannot survive being copied out, it is not finished",
	"Write down what it assumes, not only what it does",
];

const {
	eyebrow = "03 — Process",
	heading = "Fewer parts, decided once, written down.",
	intro = "Every component in this catalog goes through the same five steps.",
	steps = defaultSteps,
	principles = defaultPrinciples,
	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;
const hasPrinciples = principles.length > 0;
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;
};
---

<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">
		<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">
					{
						steps.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">
							{principles.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>