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.