Component library
Back to catalog

Scroll progress timeline

A history timeline: centred heading, then a rail down the middle with numbered markers and milestone cards alternating sides. An orange fill runs down the rail as you scroll and each marker it passes turns orange.

timelinehistorymilestonesscrollprogresstideway

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/scroll-progress-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: "Scroll progress timeline"
Library page: https://astro.baysixmedia.com/components/scroll-progress-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/ScrollProgressTimeline.astro =====
---
/**
 * ScrollProgressTimeline
 *
 * A history timeline: centred eyebrow and big uppercase heading, then a
 * vertical rail down the middle with numbered markers and milestone cards
 * alternating right and left. As the page scrolls, an orange fill runs down
 * the rail, tracking the middle of the viewport, and each marker it passes
 * turns orange.
 *
 * Without JavaScript, or under reduced motion, the rail is shown filled and
 * every marker reached. On a phone the rail moves to the left edge and every
 * card sits to its right.
 *
 * Heading syntax: wrap the orange words in asterisks.
 *
 * Reads the --tw-* tokens from any ancestor, with fallbacks.
 */

export interface TimelineMilestone {
	period: string;
	title: string;
	text: string;
}

export interface ScrollProgressTimelineProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	milestones?: TimelineMilestone[];
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ScrollProgressTimelineProps {}

const {
	eyebrow = "Our History",
	heading = "",
	headingLevel = "h2",
	/* No built-in history: every milestone comes from the caller. */
	milestones = [],
	background = "#ffffff",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
	class: className,
	id,
} = Astro.props;

const words = heading
	.split("*")
	.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const pad = (n: number) => String(n).padStart(2, "0");
---

{milestones.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{milestones.length > 0 && (
<section class:list={["spt", className]} id={id} style={`--spt-band:${background}`} data-spt-root>
	<div class="spt-inner">
		{(eyebrow || words.length > 0) && (
		<div class="spt-head">
			{eyebrow && <p class="spt-eyebrow">{eyebrow}</p>}
			{words.length > 0 && (
			<Heading class="spt-heading" data-spt-reveal="words">
				{
					words.map((t, i) => (
						<>
							{i > 0 && " "}
							<span class:list={["spt-w", { "spt-accent": t.accent }]} style={`--i:${i}`}>
								{t.w}
							</span>
						</>
					))
				}
			</Heading>
			)}
		</div>
		)}

		<div class="spt-stage">
			<div class="spt-rail" aria-hidden="true">
				<span class="spt-fill" data-spt-fill></span>
			</div>
			<ol class="spt-list">
				{
					milestones.map((m, i) => (
						<li class:list={["spt-item", i % 2 === 0 ? "spt-item--right" : "spt-item--left"]} data-spt-item>
							<span class="spt-marker" aria-hidden="true">
								<span>{pad(i + 1)}</span>
							</span>
							<div class="spt-card">
								<p class="spt-period">
									<svg viewBox="0 0 12 12" aria-hidden="true">
										<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1" />
										<path d="M6 3.8V6l1.5 1" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" />
									</svg>
									{m.period}
								</p>
								<Title class="spt-title">{m.title}</Title>
								<p class="spt-text">{m.text}</p>
							</div>
						</li>
					))
				}
			</ol>
		</div>
	</div>
</section>
)}

<style>
	.spt {
		--spt-navy: var(--tw-navy, #0b4654);
		--spt-accent: var(--tw-accent, #ff6d45);
		--spt-muted: var(--tw-muted, #616161);
		--spt-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
		--spt-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
		--spt-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
		--spt-ease: cubic-bezier(0.44, 0, 0.56, 1);
		--spt-rail-x: 20px;

		background: var(--spt-band);
		color: var(--spt-navy);
		font-family: var(--spt-body);
	}

	.spt *,
	.spt *::before,
	.spt *::after {
		box-sizing: border-box;
	}

	.spt-inner {
		max-width: 1310px;
		margin: 0 auto;
		padding: 64px 16px;
	}

	.spt-head {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 18px;
		padding-bottom: 44px;
		border-bottom: 1px solid rgb(11 70 84 / 0.1);
		text-align: center;
	}

	.spt-eyebrow {
		margin: 0;
		padding: 4px 14px;
		border: 1px solid var(--spt-accent);
		border-radius: 999px;
		color: var(--spt-accent);
		font: 500 15px/24px var(--spt-body);
		letter-spacing: -0.3px;
		text-transform: uppercase;
	}

	.spt-heading {
		max-width: 640px;
		margin: 0;
		font: 700 28px/1.2 var(--spt-display);
		letter-spacing: -0.56px;
		text-transform: uppercase;
	}

	.spt-w {
		display: inline-block;
	}

	.spt-accent {
		color: var(--spt-accent);
	}

	.spt-stage {
		position: relative;
		max-width: 1170px;
		margin: 0 auto;
		padding: 48px 0 56px;
		border-bottom: 1px solid rgb(11 70 84 / 0.1);
	}

	.spt-rail {
		position: absolute;
		top: 0;
		bottom: 56px;
		left: var(--spt-rail-x);
		width: 4px;
		border-radius: 2px;
		background: #e8eef0;
		transform: translateX(-50%);
		overflow: hidden;
	}

	.spt-fill {
		position: absolute;
		inset: 0;
		background: var(--spt-accent);
		transform-origin: top;
		transform: scaleY(var(--spt-progress, 1));
	}

	.spt-list {
		display: grid;
		gap: 32px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.spt-item {
		position: relative;
		padding-left: calc(var(--spt-rail-x) + 32px);
	}

	.spt-marker {
		position: absolute;
		top: 0;
		left: var(--spt-rail-x);
		display: grid;
		place-items: center;
		width: 41px;
		height: 41px;
		border-radius: 50%;
		background: #fff;
		box-shadow: 0 0 0 1px rgb(11 70 84 / 0.1);
		transform: translateX(-50%);
	}

	.spt-marker span {
		display: grid;
		place-items: center;
		width: 30px;
		height: 30px;
		border-radius: 50%;
		background: #f3f6f7;
		color: #18323f;
		font: 800 12px/1 var(--spt-card-font);
		transition:
			background-color 0.35s var(--spt-ease),
			color 0.35s var(--spt-ease);
	}

	.spt-item[data-reached] .spt-marker {
		box-shadow: 0 0 0 1px var(--spt-accent);
	}

	.spt-item[data-reached] .spt-marker span {
		background: var(--spt-accent);
		color: #fff;
	}

	.spt-card {
		padding: 26px 24px;
		border-radius: 12px;
		background: #f5f5f5;
	}

	.spt-period {
		display: inline-flex;
		align-items: center;
		gap: 5px;
		margin: 0;
		padding: 5px 10px;
		border-radius: 999px;
		background: var(--spt-navy);
		color: #fff;
		font: 600 10px/12px var(--spt-card-font);
	}

	.spt-period svg {
		width: 10px;
		height: 10px;
	}

	.spt-title {
		margin: 18px 0 0;
		font: 600 21px/1.2 var(--spt-card-font);
		letter-spacing: -0.84px;
	}

	.spt-text {
		margin: 16px 0 0;
		color: var(--spt-muted);
		font: 400 16px/25.6px var(--spt-body);
		letter-spacing: -0.32px;
	}

	.spt-w {
		transition:
			opacity 0.6s var(--spt-ease) calc(var(--i) * 0.06s),
			transform 0.6s var(--spt-ease) calc(var(--i) * 0.06s);
	}

	.spt[data-spt-ready] [data-spt-reveal="words"]:not([data-in]) .spt-w {
		opacity: 0;
		transform: translateY(10px);
	}

	/* No JS: rail filled, every marker reached. */
	.spt:not([data-spt-ready]) .spt-marker {
		box-shadow: 0 0 0 1px var(--spt-accent);
	}

	.spt:not([data-spt-ready]) .spt-marker span {
		background: var(--spt-accent);
		color: #fff;
	}

	@media (min-width: 810px) {
		.spt-inner {
			padding: 90px 40px;
		}

		.spt {
			--spt-rail-x: 50%;
		}

		.spt-heading {
			font-size: 38px;
			letter-spacing: -0.76px;
		}

		.spt-list {
			gap: 0;
		}

		.spt-item {
			width: 50%;
			min-height: 214px;
			padding-left: 0;
		}

		.spt-item--right {
			margin-left: 50%;
			padding-left: 42px;
		}

		.spt-item--left {
			padding-right: 42px;
		}

		.spt-item--right .spt-marker {
			left: 0;
		}

		.spt-item--left .spt-marker {
			left: 100%;
		}

		.spt-item + .spt-item {
			margin-top: -16px;
		}
	}

	@media (min-width: 1200px) {
		.spt-inner {
			padding: 100px 65px;
		}

		.spt-heading {
			font-size: 50px;
			line-height: 60px;
			letter-spacing: -1px;
		}

		.spt-head {
			padding-bottom: 44px;
			margin: 0 auto;
			max-width: 880px;
		}

		.spt-stage {
			padding: 48px 60px 54px;
		}

		.spt-item {
			min-height: 214px;
		}

		.spt-item--right {
			padding-left: 43px;
		}

		.spt-item--left {
			padding-right: 43px;
		}

		.spt-card {
			max-width: 470px;
			min-height: 230px;
			padding: 32px 32px 34px;
		}

		.spt-item--left .spt-card {
			margin-left: auto;
		}

		.spt-title {
			font-size: 24px;
			line-height: 28.8px;
			letter-spacing: -0.96px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.spt-w,
		.spt-marker span {
			transition: none;
		}
	}
</style>

<script>
	/*
	 * Scroll-linked progress. One passive scroll listener and one resize
	 * listener per instance, both on an AbortController aborted on
	 * astro:before-swap; work is batched into one requestAnimationFrame.
	 * Under reduced motion the rail stays filled and nothing listens.
	 */
	function initScrollProgressTimeline(root: HTMLElement) {
		if (root.dataset.sptReady === "") return;
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
		const rail = root.querySelector<HTMLElement>(".spt-rail");
		const items = [...root.querySelectorAll<HTMLElement>("[data-spt-item]")];
		if (!rail) return;

		const controller = new AbortController();
		const { signal } = controller;
		root.dataset.sptReady = "";

		let frame = 0;
		const update = () => {
			frame = 0;
			const r = rail.getBoundingClientRect();
			const probe = window.innerHeight * 0.55;
			const progress = r.height > 0 ? Math.min(1, Math.max(0, (probe - r.top) / r.height)) : 1;
			root.style.setProperty("--spt-progress", progress.toFixed(4));
			for (const item of items) {
				const marker = item.querySelector<HTMLElement>(".spt-marker");
				if (!marker) continue;
				const m = marker.getBoundingClientRect();
				item.toggleAttribute("data-reached", m.top + m.height / 2 <= probe);
			}
		};
		const schedule = () => {
			if (!frame) frame = requestAnimationFrame(update);
		};
		update();
		window.addEventListener("scroll", schedule, { passive: true, signal });
		window.addEventListener("resize", schedule, { passive: true, signal });

		if ("IntersectionObserver" in window) {
			const heading = root.querySelector<HTMLElement>("[data-spt-reveal]");
			if (heading) {
				const io = new IntersectionObserver(
					(entries) => {
						if (entries.some((e) => e.isIntersecting)) {
							heading.dataset.in = "";
							io.disconnect();
						}
					},
					{ threshold: 0.1 },
				);
				io.observe(heading);
				signal.addEventListener("abort", () => io.disconnect());
			}
		} else {
			root.querySelectorAll<HTMLElement>("[data-spt-reveal]").forEach((h) => (h.dataset.in = ""));
		}

		signal.addEventListener("abort", () => {
			if (frame) cancelAnimationFrame(frame);
		});
		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.sptReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () => document.querySelectorAll<HTMLElement>("[data-spt-root]").forEach(initScrollProgressTimeline);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>

===== END src/components/ScrollProgressTimeline.astro =====

===== FILE (supporting): src/components/ScrollProgressTimeline.md =====
# Scroll progress timeline

A history timeline: centred eyebrow and big uppercase heading, then a rail
down the middle with numbered markers and milestone cards alternating right
and left. As the page scrolls an orange fill runs down the rail, tracking the
point 55% down the viewport, and each marker it passes turns orange.

Catalog page: `/components/scroll-progress-timeline`

## Files required

- **`ScrollProgressTimeline.astro`** — the component and its scroll script.
- `ScrollProgressTimeline.md` — this file.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `eyebrow` | `string?` | `"Our History"` | `""` drops it. |
| `heading`, `headingLevel` | | `""`, `"h2"` | Orange words in `*asterisks*`. Omitted when empty. |
| `milestones` | `{ period, title, text }[]` | `[]` | Newest first, as in the reference. No milestones → the component renders nothing. |

The component carries no history of its own: the years, titles and copy come
from the caller. The catalog preview passes a fictional demo history through
its registry props.
| `background` | `string?` | `#ffffff` | |
| `fontStylesheet`, `class`, `id` | | | |

## Behaviour (measured)

- The fill is `scaleY(progress)` on the rail; markers get `data-reached` when
  their centre crosses the probe line, turning the inner disc `#f3f6f7` →
  `#ff6d45` and the number dark → white.
- One passive scroll listener and one resize listener per instance, batched
  into a single `requestAnimationFrame`, removed on `astro:before-swap`.
- Without JavaScript or under reduced motion the rail is filled and every
  marker reached.

## Layout

- ≥ 810px: rail centred, cards alternate, each 470px wide at desktop.
- < 810px: the rail runs down the left edge and every card sits to its right.

## Approximations

- The probe line (55% of the viewport) is chosen so the fill leads the reader
  slightly; the reference's exact trigger was not measured.
- Card overlap between sides (the reference staggers them by ~213px) is
  approximated with a small negative margin.

===== END src/components/ScrollProgressTimeline.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: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

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

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
Ships script
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
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 fixtureOpen the ClientRouter fixture

src/components/library/ScrollProgressTimeline.astro

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

---
/**
 * ScrollProgressTimeline
 *
 * A history timeline: centred eyebrow and big uppercase heading, then a
 * vertical rail down the middle with numbered markers and milestone cards
 * alternating right and left. As the page scrolls, an orange fill runs down
 * the rail, tracking the middle of the viewport, and each marker it passes
 * turns orange.
 *
 * Without JavaScript, or under reduced motion, the rail is shown filled and
 * every marker reached. On a phone the rail moves to the left edge and every
 * card sits to its right.
 *
 * Heading syntax: wrap the orange words in asterisks.
 *
 * Reads the --tw-* tokens from any ancestor, with fallbacks.
 */

export interface TimelineMilestone {
	period: string;
	title: string;
	text: string;
}

export interface ScrollProgressTimelineProps {
	eyebrow?: string;
	heading?: string;
	headingLevel?: "h2" | "h3";
	milestones?: TimelineMilestone[];
	background?: string;
	fontStylesheet?: string;
	class?: string;
	id?: string;
}

interface Props extends ScrollProgressTimelineProps {}

const {
	eyebrow = "Our History",
	heading = "",
	headingLevel = "h2",
	/* No built-in history: every milestone comes from the caller. */
	milestones = [],
	background = "#ffffff",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
	class: className,
	id,
} = Astro.props;

const words = heading
	.split("*")
	.flatMap((seg, i) => seg.split(/\s+/).filter(Boolean).map((w) => ({ w, accent: i % 2 === 1 })));
const Heading = headingLevel;
const Title = headingLevel === "h2" ? "h3" : "h4";
const pad = (n: number) => String(n).padStart(2, "0");
---

{milestones.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{milestones.length > 0 && (
<section class:list={["spt", className]} id={id} style={`--spt-band:${background}`} data-spt-root>
	<div class="spt-inner">
		{(eyebrow || words.length > 0) && (
		<div class="spt-head">
			{eyebrow && <p class="spt-eyebrow">{eyebrow}</p>}
			{words.length > 0 && (
			<Heading class="spt-heading" data-spt-reveal="words">
				{
					words.map((t, i) => (
						<>
							{i > 0 && " "}
							<span class:list={["spt-w", { "spt-accent": t.accent }]} style={`--i:${i}`}>
								{t.w}
							</span>
						</>
					))
				}
			</Heading>
			)}
		</div>
		)}

		<div class="spt-stage">
			<div class="spt-rail" aria-hidden="true">
				<span class="spt-fill" data-spt-fill></span>
			</div>
			<ol class="spt-list">
				{
					milestones.map((m, i) => (
						<li class:list={["spt-item", i % 2 === 0 ? "spt-item--right" : "spt-item--left"]} data-spt-item>
							<span class="spt-marker" aria-hidden="true">
								<span>{pad(i + 1)}</span>
							</span>
							<div class="spt-card">
								<p class="spt-period">
									<svg viewBox="0 0 12 12" aria-hidden="true">
										<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1" />
										<path d="M6 3.8V6l1.5 1" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" />
									</svg>
									{m.period}
								</p>
								<Title class="spt-title">{m.title}</Title>
								<p class="spt-text">{m.text}</p>
							</div>
						</li>
					))
				}
			</ol>
		</div>
	</div>
</section>
)}

<style>
	.spt {
		--spt-navy: var(--tw-navy, #0b4654);
		--spt-accent: var(--tw-accent, #ff6d45);
		--spt-muted: var(--tw-muted, #616161);
		--spt-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
		--spt-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
		--spt-card-font: var(--tw-card, Inter, ui-sans-serif, system-ui, sans-serif);
		--spt-ease: cubic-bezier(0.44, 0, 0.56, 1);
		--spt-rail-x: 20px;

		background: var(--spt-band);
		color: var(--spt-navy);
		font-family: var(--spt-body);
	}

	.spt *,
	.spt *::before,
	.spt *::after {
		box-sizing: border-box;
	}

	.spt-inner {
		max-width: 1310px;
		margin: 0 auto;
		padding: 64px 16px;
	}

	.spt-head {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 18px;
		padding-bottom: 44px;
		border-bottom: 1px solid rgb(11 70 84 / 0.1);
		text-align: center;
	}

	.spt-eyebrow {
		margin: 0;
		padding: 4px 14px;
		border: 1px solid var(--spt-accent);
		border-radius: 999px;
		color: var(--spt-accent);
		font: 500 15px/24px var(--spt-body);
		letter-spacing: -0.3px;
		text-transform: uppercase;
	}

	.spt-heading {
		max-width: 640px;
		margin: 0;
		font: 700 28px/1.2 var(--spt-display);
		letter-spacing: -0.56px;
		text-transform: uppercase;
	}

	.spt-w {
		display: inline-block;
	}

	.spt-accent {
		color: var(--spt-accent);
	}

	.spt-stage {
		position: relative;
		max-width: 1170px;
		margin: 0 auto;
		padding: 48px 0 56px;
		border-bottom: 1px solid rgb(11 70 84 / 0.1);
	}

	.spt-rail {
		position: absolute;
		top: 0;
		bottom: 56px;
		left: var(--spt-rail-x);
		width: 4px;
		border-radius: 2px;
		background: #e8eef0;
		transform: translateX(-50%);
		overflow: hidden;
	}

	.spt-fill {
		position: absolute;
		inset: 0;
		background: var(--spt-accent);
		transform-origin: top;
		transform: scaleY(var(--spt-progress, 1));
	}

	.spt-list {
		display: grid;
		gap: 32px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.spt-item {
		position: relative;
		padding-left: calc(var(--spt-rail-x) + 32px);
	}

	.spt-marker {
		position: absolute;
		top: 0;
		left: var(--spt-rail-x);
		display: grid;
		place-items: center;
		width: 41px;
		height: 41px;
		border-radius: 50%;
		background: #fff;
		box-shadow: 0 0 0 1px rgb(11 70 84 / 0.1);
		transform: translateX(-50%);
	}

	.spt-marker span {
		display: grid;
		place-items: center;
		width: 30px;
		height: 30px;
		border-radius: 50%;
		background: #f3f6f7;
		color: #18323f;
		font: 800 12px/1 var(--spt-card-font);
		transition:
			background-color 0.35s var(--spt-ease),
			color 0.35s var(--spt-ease);
	}

	.spt-item[data-reached] .spt-marker {
		box-shadow: 0 0 0 1px var(--spt-accent);
	}

	.spt-item[data-reached] .spt-marker span {
		background: var(--spt-accent);
		color: #fff;
	}

	.spt-card {
		padding: 26px 24px;
		border-radius: 12px;
		background: #f5f5f5;
	}

	.spt-period {
		display: inline-flex;
		align-items: center;
		gap: 5px;
		margin: 0;
		padding: 5px 10px;
		border-radius: 999px;
		background: var(--spt-navy);
		color: #fff;
		font: 600 10px/12px var(--spt-card-font);
	}

	.spt-period svg {
		width: 10px;
		height: 10px;
	}

	.spt-title {
		margin: 18px 0 0;
		font: 600 21px/1.2 var(--spt-card-font);
		letter-spacing: -0.84px;
	}

	.spt-text {
		margin: 16px 0 0;
		color: var(--spt-muted);
		font: 400 16px/25.6px var(--spt-body);
		letter-spacing: -0.32px;
	}

	.spt-w {
		transition:
			opacity 0.6s var(--spt-ease) calc(var(--i) * 0.06s),
			transform 0.6s var(--spt-ease) calc(var(--i) * 0.06s);
	}

	.spt[data-spt-ready] [data-spt-reveal="words"]:not([data-in]) .spt-w {
		opacity: 0;
		transform: translateY(10px);
	}

	/* No JS: rail filled, every marker reached. */
	.spt:not([data-spt-ready]) .spt-marker {
		box-shadow: 0 0 0 1px var(--spt-accent);
	}

	.spt:not([data-spt-ready]) .spt-marker span {
		background: var(--spt-accent);
		color: #fff;
	}

	@media (min-width: 810px) {
		.spt-inner {
			padding: 90px 40px;
		}

		.spt {
			--spt-rail-x: 50%;
		}

		.spt-heading {
			font-size: 38px;
			letter-spacing: -0.76px;
		}

		.spt-list {
			gap: 0;
		}

		.spt-item {
			width: 50%;
			min-height: 214px;
			padding-left: 0;
		}

		.spt-item--right {
			margin-left: 50%;
			padding-left: 42px;
		}

		.spt-item--left {
			padding-right: 42px;
		}

		.spt-item--right .spt-marker {
			left: 0;
		}

		.spt-item--left .spt-marker {
			left: 100%;
		}

		.spt-item + .spt-item {
			margin-top: -16px;
		}
	}

	@media (min-width: 1200px) {
		.spt-inner {
			padding: 100px 65px;
		}

		.spt-heading {
			font-size: 50px;
			line-height: 60px;
			letter-spacing: -1px;
		}

		.spt-head {
			padding-bottom: 44px;
			margin: 0 auto;
			max-width: 880px;
		}

		.spt-stage {
			padding: 48px 60px 54px;
		}

		.spt-item {
			min-height: 214px;
		}

		.spt-item--right {
			padding-left: 43px;
		}

		.spt-item--left {
			padding-right: 43px;
		}

		.spt-card {
			max-width: 470px;
			min-height: 230px;
			padding: 32px 32px 34px;
		}

		.spt-item--left .spt-card {
			margin-left: auto;
		}

		.spt-title {
			font-size: 24px;
			line-height: 28.8px;
			letter-spacing: -0.96px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.spt-w,
		.spt-marker span {
			transition: none;
		}
	}
</style>

<script>
	/*
	 * Scroll-linked progress. One passive scroll listener and one resize
	 * listener per instance, both on an AbortController aborted on
	 * astro:before-swap; work is batched into one requestAnimationFrame.
	 * Under reduced motion the rail stays filled and nothing listens.
	 */
	function initScrollProgressTimeline(root: HTMLElement) {
		if (root.dataset.sptReady === "") return;
		if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
		const rail = root.querySelector<HTMLElement>(".spt-rail");
		const items = [...root.querySelectorAll<HTMLElement>("[data-spt-item]")];
		if (!rail) return;

		const controller = new AbortController();
		const { signal } = controller;
		root.dataset.sptReady = "";

		let frame = 0;
		const update = () => {
			frame = 0;
			const r = rail.getBoundingClientRect();
			const probe = window.innerHeight * 0.55;
			const progress = r.height > 0 ? Math.min(1, Math.max(0, (probe - r.top) / r.height)) : 1;
			root.style.setProperty("--spt-progress", progress.toFixed(4));
			for (const item of items) {
				const marker = item.querySelector<HTMLElement>(".spt-marker");
				if (!marker) continue;
				const m = marker.getBoundingClientRect();
				item.toggleAttribute("data-reached", m.top + m.height / 2 <= probe);
			}
		};
		const schedule = () => {
			if (!frame) frame = requestAnimationFrame(update);
		};
		update();
		window.addEventListener("scroll", schedule, { passive: true, signal });
		window.addEventListener("resize", schedule, { passive: true, signal });

		if ("IntersectionObserver" in window) {
			const heading = root.querySelector<HTMLElement>("[data-spt-reveal]");
			if (heading) {
				const io = new IntersectionObserver(
					(entries) => {
						if (entries.some((e) => e.isIntersecting)) {
							heading.dataset.in = "";
							io.disconnect();
						}
					},
					{ threshold: 0.1 },
				);
				io.observe(heading);
				signal.addEventListener("abort", () => io.disconnect());
			}
		} else {
			root.querySelectorAll<HTMLElement>("[data-spt-reveal]").forEach((h) => (h.dataset.in = ""));
		}

		signal.addEventListener("abort", () => {
			if (frame) cancelAnimationFrame(frame);
		});
		document.addEventListener(
			"astro:before-swap",
			() => {
				controller.abort();
				delete root.dataset.sptReady;
			},
			{ once: true, signal },
		);
	}

	const boot = () => document.querySelectorAll<HTMLElement>("[data-spt-root]").forEach(initScrollProgressTimeline);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	document.addEventListener("astro:page-load", boot);
</script>