Component library
Back to catalog

Pinned feature tour

A feature panel that pins to the viewport and advances step by step as the page scrolls past it.

sectionscrollstickyfeaturesmedia

Open preview in a new tab

Use this component

Three ways to get Pinned feature tour 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 6 files 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 Pinned feature tour from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/pinned-feature-tour/

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 6 files 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: "Pinned feature tour"
Library page: https://astro.baysixmedia.com/components/pinned-feature-tour/
Files: 6 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/PinnedFeatureTour.astro =====
---
/**
 * Pinned feature tour
 *
 * A feature showcase that pins to the viewport while the page scrolls past it.
 * Each scroll step advances to the next feature: its card expands to reveal a
 * description and link, a progress rail fills alongside it, and the media panel
 * cross-fades to that feature's artwork.
 *
 * Below the pin breakpoint — and whenever JavaScript has not run — the whole
 * thing renders as a plain stacked list with every feature open. That is the
 * fallback, not a degraded mode: all the content is present either way.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Every colour
 * and measurement is a `--ft-*` custom property on the root.
 */

export interface TourFeature {
	/** Card title, and the accessible name of its step button. */
	title: string;
	/** Revealed when the step is active, and always shown when stacked. */
	description?: string;
	/** Artwork for this step. Omit for the built-in drawn placeholder. */
	image?: string;
	/** Alternative text. Empty means decorative. */
	imageAlt?: string;
	/** Optional call to action under the description. */
	linkLabel?: string;
	/** Destination. Without one the label is not rendered as a link. */
	linkHref?: string;
}

interface Props {
	/** Optional heading above the tour. `""` or omitted renders no header. */
	heading?: string;
	/** Optional line under the heading. */
	intro?: string;
	/** For pages where `h2` is the wrong level. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The steps. Two or more is the point. */
	features?: TourFeature[];
	/** Which step is open before any scrolling has happened. 1-based. */
	initialStep?: number;
	/** Which side the media panel takes when pinned. */
	mediaSide?: "start" | "end";
	/** Pixels of page scroll each step owns. Larger is slower. */
	scrollPerStep?: number;
	/** Media / list split when pinned, as a percentage of the card. */
	mediaWidth?: number;
	/** Viewport inset above and below the pinned card, e.g. a fixed header. */
	pinInset?: string;
	/** Tallest the pinned card may get. */
	cardMaxHeight?: string;
	/** Rail fill, links and placeholder highlights. */
	accent?: string;
	/** Ground behind the whole section. */
	background?: string;
	/** The card and active step surface. */
	surface?: string;
	/** The media panel's ground. */
	stage?: string;
	/** Hairlines around the card, the panel and each step. */
	border?: string;
	/** Primary text colour. */
	ink?: string;
	/** Secondary text colour. */
	muted?: string;
	/** Corner radius of the outer card. */
	radius?: string;
	/** Caps the card width and centres it. */
	maxWidth?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the step and panel ids. */
	id?: string;
}

const {
	heading = "",
	intro = "",
	headingLevel = "h2",
	features: featuresProp = [],
	initialStep = 1,
	mediaSide = "start",
	scrollPerStep = 540,
	mediaWidth = 60,
	pinInset = "40px",
	cardMaxHeight = "720px",
	accent = "#ff8833",
	background = "#0a0a0b",
	surface = "#141418",
	stage = "#0c0c0e",
	border = "#2a2a31",
	ink = "#ffffff",
	muted = "#a3a3ad",
	radius = "16px",
	maxWidth = "72rem",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

// A step with no title has no button name, so it is dropped. No steps, no
// section: a heading over an empty runway is not a tour.
const features = featuresProp.filter((feature) => feature && feature.title);

// One id prefix per instance, so several tours can share a page without their
// buttons and panels pointing at each other's elements.
const uid = id ?? `ft-${Math.random().toString(36).slice(2, 9)}`;

const count = Math.max(features.length, 1);
// 1-based in, 0-based out, clamped rather than trusted.
const initial = Math.min(Math.max(Math.trunc(initialStep), 1), count) - 1;

const listWidth = Math.min(Math.max(100 - mediaWidth, 20), 80);

const style = [
	`--ft-accent:${accent}`,
	`--ft-bg:${background}`,
	`--ft-surface:${surface}`,
	`--ft-stage:${stage}`,
	`--ft-border:${border}`,
	`--ft-ink:${ink}`,
	`--ft-muted:${muted}`,
	`--ft-radius:${radius}`,
	`--ft-max:${maxWidth}`,
	`--ft-pin-inset:${pinInset}`,
	`--ft-card-max:${cardMaxHeight}`,
	`--ft-media-w:${Math.min(Math.max(mediaWidth, 20), 80)}fr`,
	`--ft-list-w:${listWidth}fr`,
	`--ft-step:${scrollPerStep}px`,
	`--ft-count:${count}`,
].join(";");
---

{features.length > 0 && (
<section
	class:list={["tour", className]}
	id={id}
	style={style}
	data-ft-root
	data-ft-initial={initial}
>
	{
		(heading || intro) && (
			<header class="head">
				{heading && <Heading class="heading">{heading}</Heading>}
				{intro && <p class="intro">{intro}</p>}
			</header>
		)
	}

	{/* The runway is the scroll distance the pinned card consumes. Its height is
	    the viewport plus one step per feature, which is what turns page scroll
	    into step progress. Stacked, it collapses to auto. */}
	<div class="runway" data-ft-runway>
		<div class="pin" data-ft-pin>
			<div class="card" data-media={mediaSide}>
				{/* Media panel. Every step's artwork is stacked in one grid cell;
				    only the active one is opaque and at full scale. */}
				<div class="stage">
					<span class="dots" aria-hidden="true"></span>
					{
						features.map((feature, i) => (
							<figure
								class="shot"
								id={`${uid}-shot-${i}`}
								data-ft-shot
								data-on={i === initial ? "" : null}
							>
								{feature.image ? (
									<img
										src={feature.image}
										alt={feature.imageAlt ?? ""}
										loading="lazy"
										decoding="async"
									/>
								) : (
									<span class="placeholder" aria-hidden="true">
										<span class="ph-bar"></span>
										<span class="ph-bar"></span>
										<span class="ph-bar"></span>
									</span>
								)}
							</figure>
						))
					}
				</div>

				<ol class="list" data-ft-list>
					{
						features.map((feature, i) => (
							<li class="item" data-ft-item data-on={i === initial ? "" : null}>
								{/* The rail sits outside the button so it can span the whole
								    card while the button's accessible name stays the title. */}
								<span class="rail" aria-hidden="true">
									<span class="fill"></span>
								</span>

								<h3 class="title">
									<button
										type="button"
										class="jump"
										data-ft-jump
										data-index={i}
										aria-controls={`${uid}-shot-${i}`}
										aria-current={i === initial ? "step" : null}
									>
										{feature.title}
									</button>
								</h3>

								<div class="body">
									<div class="body-inner">
										{feature.description && <p class="desc">{feature.description}</p>}
										{feature.linkLabel &&
											(feature.linkHref ? (
												<a class="more" href={feature.linkHref}>
													{feature.linkLabel}
													<span class="arrow" aria-hidden="true">→</span>
												</a>
											) : (
												/* No destination, so no link: a dead anchor that
												   looks clickable is worse than plain text. */
												<span class="more is-static">{feature.linkLabel}</span>
											))}
									</div>
								</div>

								{/* Shown only when stacked, where there is no media panel. */}
								<div class="inline-shot" aria-hidden={feature.image ? null : "true"}>
									{feature.image ? (
										<img
											src={feature.image}
											alt={feature.imageAlt ?? ""}
											loading="lazy"
											decoding="async"
										/>
									) : (
										<span class="placeholder">
											<span class="ph-bar"></span>
											<span class="ph-bar"></span>
											<span class="ph-bar"></span>
										</span>
									)}
								</div>
							</li>
						))
					}
				</ol>
			</div>
		</div>
	</div>
</section>
)}

<style>
	/* --- root and defensive resets ----------------------------------------- */

	.tour {
		box-sizing: border-box;
		padding: clamp(32px, 5vw, 64px) clamp(16px, 4vw, 32px);
		background: var(--ft-bg);
		color: var(--ft-ink);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
		font-size: 16px;
		line-height: 1.5;
		text-align: start;
	}

	/* A destination site's global rules must not be able to move this
	   component's geometry, so every box it owns is reset rather than assumed. */
	.tour *,
	.tour *::before,
	.tour *::after {
		box-sizing: border-box;
	}

	/* `li` is deliberately absent: it has no default padding to reset, and
	   including it would out-specify `.item`'s own padding below. */
	.tour h2,
	.tour h3,
	.tour p,
	.tour figure,
	.tour ol {
		margin: 0;
		padding: 0;
	}

	.tour li {
		margin: 0;
	}

	.tour ol {
		list-style: none;
	}

	.tour img {
		display: block;
		max-width: 100%;
	}

	/* --- header ------------------------------------------------------------- */

	.head {
		max-width: var(--ft-max);
		margin: 0 auto clamp(24px, 3vw, 40px);
	}

	.heading {
		font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem);
		font-weight: 600;
		line-height: 1.15;
		letter-spacing: -0.02em;
	}

	.intro {
		margin-top: 12px;
		max-width: 52ch;
		color: var(--ft-muted);
		font-size: 1.0625rem;
	}

	/* --- stacked: the default, and the no-JS result ------------------------- */

	.runway {
		max-width: var(--ft-max);
		margin-inline: auto;
	}

	.pin {
		display: block;
	}

	.card {
		display: grid;
		gap: 16px;
		padding: 16px;
		border: 1px solid var(--ft-border);
		border-radius: var(--ft-radius);
	}

	.stage {
		display: none;
	}

	.list {
		display: grid;
		gap: 16px;
		min-width: 0;
	}

	.item {
		position: relative;
		padding: 16px 16px 16px 32px;
		border: 1px solid var(--ft-border);
		border-radius: 10px;
		background: var(--ft-surface);
		min-width: 0;
	}

	.rail {
		position: absolute;
		top: 16px;
		bottom: 16px;
		left: 12px;
		width: 4px;
		border-radius: 999px;
		background: var(--ft-border);
		overflow: hidden;
	}

	.fill {
		display: block;
		width: 100%;
		height: 100%;
		border-radius: inherit;
		background: var(--ft-accent);
	}

	.title {
		font-size: 1.25rem;
		font-weight: 500;
		line-height: 1.4;
		letter-spacing: -0.01em;
	}

	/* The button carries no chrome of its own: the card around it is the
	   affordance. It still has to be reset, or a host stylesheet's button rules
	   will repaint it. */
	.jump {
		appearance: none;
		margin: 0;
		padding: 0;
		border: 0;
		background: none;
		color: inherit;
		font: inherit;
		letter-spacing: inherit;
		text-align: inherit;
		cursor: pointer;
	}

	.jump:focus-visible {
		outline: 3px solid var(--ft-accent);
		outline-offset: 4px;
		border-radius: 3px;
	}

	.desc {
		color: var(--ft-muted);
		font-size: 1.0625rem;
		line-height: 1.35;
	}

	.more {
		display: inline-flex;
		align-items: center;
		gap: 0.4em;
		margin-top: 14px;
		color: var(--ft-accent);
		font-size: 1rem;
		text-decoration: none;
	}

	.more:hover .arrow {
		transform: translateX(3px);
	}

	.more:focus-visible {
		outline: 3px solid var(--ft-accent);
		outline-offset: 3px;
		border-radius: 3px;
	}

	.more.is-static {
		color: var(--ft-muted);
		cursor: default;
	}

	.arrow {
		transition: transform 200ms ease-out;
	}

	.body-inner {
		padding-top: 10px;
	}

	.inline-shot {
		margin-top: 18px;
		overflow: hidden;
		aspect-ratio: 4 / 3;
		border: 1px solid var(--ft-border);
		border-radius: 10px;
		background: var(--ft-stage);
	}

	.inline-shot img {
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Drawn, not loaded: a component with no artwork yet still has to look
	   deliberate rather than broken. */
	.placeholder {
		display: flex;
		flex-direction: column;
		justify-content: center;
		gap: 10px;
		width: 100%;
		height: 100%;
		padding: 14%;
		background:
			radial-gradient(
				circle at 50% 0%,
				color-mix(in srgb, var(--ft-accent) 18%, transparent),
				transparent 70%
			),
			var(--ft-stage);
	}

	.ph-bar {
		display: block;
		height: 10px;
		border-radius: 999px;
		background: color-mix(in srgb, var(--ft-ink) 16%, transparent);
	}

	.ph-bar:first-child {
		width: 62%;
		background: color-mix(in srgb, var(--ft-accent) 55%, transparent);
	}

	.ph-bar:nth-child(2) {
		width: 90%;
	}

	.ph-bar:last-child {
		width: 44%;
	}

	/* --- pinned: only once the script has run, and only when there is room --- */

	@media (min-width: 768px) {
		[data-ft-ready] .runway {
			/* One viewport to pin in, plus one step of scroll per feature. */
			height: calc(100vh + var(--ft-count) * var(--ft-step));
			height: calc(100dvh + var(--ft-count) * var(--ft-step));
		}

		[data-ft-ready] .pin {
			position: sticky;
			top: 0;
			display: flex;
			align-items: center;
			height: 100vh;
			height: 100dvh;
			padding-block: var(--ft-pin-inset);
		}

		[data-ft-ready] .card {
			grid-template-columns: var(--ft-media-w) var(--ft-list-w);
			align-items: stretch;
			width: 100%;
			max-height: var(--ft-card-max);
			height: 100%;
		}

		[data-ft-ready] .card[data-media="end"] {
			grid-template-columns: var(--ft-list-w) var(--ft-media-w);
		}

		[data-ft-ready] .card[data-media="start"] .stage {
			grid-column: 1;
		}

		[data-ft-ready] .card[data-media="start"] .list {
			grid-column: 2;
		}

		[data-ft-ready] .card[data-media="end"] .stage {
			grid-column: 2;
		}

		[data-ft-ready] .card[data-media="end"] .list {
			grid-column: 1;
		}

		[data-ft-ready] .stage {
			position: relative;
			display: grid;
			grid-row: 1;
			overflow: hidden;
			min-width: 0;
			padding: 6%;
			border: 1px solid var(--ft-border);
			border-radius: 10px;
			background: var(--ft-stage);
		}

		[data-ft-ready] .dots {
			position: absolute;
			inset: 0;
			opacity: 0.25;
			background-image: radial-gradient(
				circle,
				color-mix(in srgb, var(--ft-ink) 45%, transparent) 1px,
				transparent 1px
			);
			background-size: 22px 22px;
			pointer-events: none;
		}

		/* Every shot occupies the same cell, so they cross-fade in place
		   instead of pushing each other around. */
		[data-ft-ready] .shot {
			grid-area: 1 / 1;
			display: grid;
			place-items: center;
			min-width: 0;
			opacity: 0;
			transform: scale(0.8);
			transition:
				opacity 500ms cubic-bezier(0, 0, 0.2, 1),
				transform 500ms cubic-bezier(0, 0, 0.2, 1);
			pointer-events: none;
		}

		[data-ft-ready] .shot[data-on] {
			opacity: 1;
			transform: scale(1);
		}

		[data-ft-ready] .shot img,
		[data-ft-ready] .shot .placeholder {
			max-height: 100%;
			width: auto;
			max-width: 100%;
			border-radius: 8px;
		}

		[data-ft-ready] .shot .placeholder {
			width: 100%;
			aspect-ratio: 4 / 3;
		}

		/* Flex, not grid, so the open step can take whatever height the collapsed
		   ones leave — which is what keeps the rail column flush with the media
		   panel beside it at any card height. */
		[data-ft-ready] .list {
			grid-row: 1;
			display: flex;
			flex-direction: column;
			min-height: 0;
		}

		[data-ft-ready] .item {
			flex: 0 0 auto;
			background: transparent;
			overflow: hidden;
			transition:
				background-color 250ms ease-out,
				border-color 250ms ease-out;
		}

		[data-ft-ready] .item[data-on] {
			flex: 1 1 auto;
			min-height: 0;
			background: var(--ft-surface);
		}

		[data-ft-ready] .rail {
			background: var(--ft-border);
		}

		[data-ft-ready] .item[data-on] .rail {
			background: color-mix(in srgb, var(--ft-ink) 20%, transparent);
		}

		/* Height is driven from script as a 0–1 fraction of the step's scroll. */
		[data-ft-ready] .fill {
			height: calc(var(--ft-fill, 0) * 100%);
			transition: height 250ms ease-out;
		}

		[data-ft-ready] .item:not([data-on]) .fill {
			height: 0;
		}

		/* Collapsed steps keep their title and hide the rest, without the
		   description leaving the accessibility tree mid-animation. */
		[data-ft-ready] .body {
			display: grid;
			grid-template-rows: 0fr;
			transition: grid-template-rows 250ms ease-out;
		}

		[data-ft-ready] .item[data-on] .body {
			grid-template-rows: 1fr;
		}

		/* `visibility` and not just `overflow`: a collapsed step's description
		   and link are invisible, so they must leave the tab order and the
		   accessibility tree too, or Tab lands on a link nobody can see.
		   Transitioning visibility keeps it on screen for the whole collapse
		   and only then hides it. */
		[data-ft-ready] .body-inner {
			overflow: hidden;
			padding-top: 0;
			visibility: hidden;
			transition: visibility 250ms;
		}

		[data-ft-ready] .item[data-on] .body-inner {
			padding-top: 10px;
			visibility: visible;
		}

		[data-ft-ready] .inline-shot {
			display: none;
		}
	}

	/* --- reduced motion ------------------------------------------------------ */

	@media (prefers-reduced-motion: reduce) {
		.tour *,
		.tour *::before,
		.tour *::after {
			transition-duration: 0.01ms !important;
			animation-duration: 0.01ms !important;
		}

		/* The cross-fade is the motion, so it goes; the step still changes. */
		[data-ft-ready] .shot {
			transform: none;
		}

		[data-ft-ready] .shot[data-on] {
			transform: none;
		}
	}
</style>

<script>
	/**
	 * One controller per instance. Every element is looked up inside its own
	 * root, so any number of tours can share a page without touching each
	 * other's state.
	 */
	function initPinnedFeatureTour(root: HTMLElement) {
		const runwayEl = root.querySelector<HTMLElement>("[data-ft-runway]");
		const pinEl = root.querySelector<HTMLElement>("[data-ft-pin]");
		const items = Array.from(root.querySelectorAll<HTMLElement>("[data-ft-item]"));
		const shots = Array.from(root.querySelectorAll<HTMLElement>("[data-ft-shot]"));
		const jumps = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ft-jump]"));
		if (!runwayEl || !pinEl || items.length === 0) return;

		// Re-bound after the guard so the narrowing survives into the handlers
		// below, which TypeScript otherwise widens back to "possibly null".
		const runway: HTMLElement = runwayEl;
		const pin: HTMLElement = pinEl;

		const count = items.length;
		let current = Number(root.dataset.ftInitial ?? 0);
		let frame = 0;

		// Turning this on is what enables the pinned layout at all, so the
		// stacked version is what renders if this script never runs.
		root.setAttribute("data-ft-ready", "");

		const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);

		/** Is the pinned layout actually in effect right now? */
		const isPinned = () => getComputedStyle(pin).position === "sticky";

		function paint(index: number, fill: number) {
			if (index !== current) {
				current = index;
				items.forEach((item, i) => {
					if (i === index) item.setAttribute("data-on", "");
					else item.removeAttribute("data-on");
				});
				shots.forEach((shot, i) => {
					if (i === index) shot.setAttribute("data-on", "");
					else shot.removeAttribute("data-on");
				});
				jumps.forEach((jump, i) => {
					if (i === index) jump.setAttribute("aria-current", "step");
					else jump.removeAttribute("aria-current");
				});
			}
			items[index].style.setProperty("--ft-fill", String(fill));
		}

		function measure() {
			if (!isPinned()) {
				// Stacked: the CSS shows everything, so nothing is driven here.
				items.forEach((item) => item.style.removeProperty("--ft-fill"));
				return;
			}
			const travel = runway.offsetHeight - pin.offsetHeight;
			if (travel <= 0) return;
			const scrolled = -runway.getBoundingClientRect().top;
			const progress = clamp(scrolled / travel, 0, 1);
			const raw = progress * count;
			const index = clamp(Math.floor(raw), 0, count - 1);
			paint(index, clamp(raw - index, 0, 1));
		}

		function schedule() {
			if (frame) return;
			frame = requestAnimationFrame(() => {
				frame = 0;
				measure();
			});
		}

		/** Scroll the page to the point where `index` becomes the active step. */
		function goTo(index: number) {
			if (!isPinned()) {
				items[index].scrollIntoView({ block: "center", behavior: "smooth" });
				return;
			}
			const travel = runway.offsetHeight - pin.offsetHeight;
			// Aim at the middle of the step's band, so a rounding error at the
			// boundary cannot land on the neighbour.
			const target =
				window.scrollY +
				runway.getBoundingClientRect().top +
				((index + 0.5) / count) * travel;
			const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
			window.scrollTo({ top: target, behavior: reduce ? "auto" : "smooth" });
		}

		jumps.forEach((jump, i) => {
			jump.addEventListener("click", () => goTo(i));
			jump.addEventListener("keydown", (event) => {
				let next: number;
				switch (event.key) {
					case "ArrowDown":
					case "ArrowRight":
						next = (i + 1) % count;
						break;
					case "ArrowUp":
					case "ArrowLeft":
						next = (i - 1 + count) % count;
						break;
					case "Home":
						next = 0;
						break;
					case "End":
						next = count - 1;
						break;
					default:
						return;
				}
				event.preventDefault();
				jumps[next].focus();
				goTo(next);
			});
		});

		window.addEventListener("scroll", schedule, { passive: true });
		window.addEventListener("resize", schedule, { passive: true });
		measure();
	}

	document
		.querySelectorAll<HTMLElement>("[data-ft-root]")
		.forEach(initPinnedFeatureTour);
</script>

===== END src/components/PinnedFeatureTour.astro =====

===== FILE (supporting): src/components/PinnedFeatureTour.md =====
# Pinned feature tour

A feature showcase that pins to the viewport while the page scrolls past it.
Each scroll step advances to the next feature: its card expands to reveal a
description and link, a progress rail fills alongside it, and the media panel
cross-fades to that feature's artwork.

Below 768px — and whenever JavaScript has not run — the whole thing renders as a
plain stacked list with every feature open, each with its own artwork. That is
the fallback, not a degraded mode: all the content is present either way.

Catalog page: `/components/pinned-feature-tour`

## Copying it into another Astro project

Copy **`PinnedFeatureTour.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--ft-*` property on the root |
| Webfonts | none — system stack |
| JavaScript | **~90 lines, inline in the file** — no library, no IntersectionObserver |
| Bundled images | **none in the component** — the demo ships five; see below |
| Network requests | none, beyond the images you pass |
| Host requirement | Astro compiles `.astro` files |

### Exact steps

1. Copy `PinnedFeatureTour.astro` into your components folder, e.g.
   `src/components/PinnedFeatureTour.astro`.
2. Render it with your own steps:

```astro
---
import PinnedFeatureTour from "../components/PinnedFeatureTour.astro";
---

<PinnedFeatureTour
  heading="What the platform does"
  features={[
    {
      title: "First capability",
      description: "One or two sentences.",
      image: "/images/one.webp",
      imageAlt: "…",
      linkLabel: "Learn more",
      linkHref: "/features/one",
    },
    { title: "Second capability", description: "…", image: "/images/two.webp", imageAlt: "…" },
    { title: "Third capability", description: "…", image: "/images/three.webp", imageAlt: "…" },
  ]}
/>
```

3. There is no step three. Omit `image` on a feature and it draws a built-in
   placeholder rather than a broken box, so you can wire the art up later.

**Give it the full page width.** The section paints its own padding and centres
the card; nesting it inside an already-padded container will fight it.

**It must be a direct child of the scrolling document.** The pin is
`position: sticky` against the page, so an ancestor with `overflow: hidden`,
`overflow: auto` or `transform` will break the pinning — the component then
behaves as if it were stacked. Nothing errors; it just stops sticking.

## The demo's image dependencies

The catalog demo passes five images, so copying it as-you-see-it needs these
files as well as the component:

```
public/pinned-feature-tour/threaded-review-1000.webp    (5 KB, 1000 × 750)
public/pinned-feature-tour/ranked-findings-1000.webp    (4 KB, 1000 × 750)
public/pinned-feature-tour/ordered-queue-1000.webp      (6 KB, 1000 × 750)
public/pinned-feature-tour/layered-branches-1000.webp   (5 KB, 1000 × 750)
public/pinned-feature-tour/focused-surface-1000.webp    (5 KB, 1000 × 750)
```

Nothing in the `.astro` file imports them — they are passed by URL — so they
will not follow the component on their own. The catalog's source panel lists
them under **assets** for that reason, and the copy button includes them.

### Where the demo images came from

They are **original**, generated for this catalog — no stock photography, no
third party, nothing traced or recreated from anywhere. They are yours to use,
change or delete with no attribution and no licence to honour.

Each is an abstract scene built as CSS: a dark radial ground, an amber bloom, a
masked dot grid, and rounded rectangles and bars standing in for interface
content. One HTML page held all five scenes; each was shown in turn, captured
headless at 1000 × 750, and re-encoded as WebP at quality 82.

| File | Scene |
| :--- | :--- |
| `threaded-review` | Alternating message cards with small coloured avatar dots |
| `ranked-findings` | Five rows, the second highlighted amber with a filled severity chip |
| `ordered-queue` | Four staggered cards feeding a vertical lane, emerging as two green tiles |
| `layered-branches` | Four cards stepped diagonally, joined by short connectors |
| `focused-surface` | A window with two columns of line bars, one tinted red and one green |

They are deliberately dark, because the stage they sit on is. Swap in light
artwork and you will want to raise `stage` and `background` to match.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `heading` | `string` | `""` | Optional heading above the tour. Empty renders no header at all. |
| `intro` | `string` | `""` | Optional line under the heading. |
| `headingLevel` | `"h1" \| "h2" \| "h3"` | `"h2"` | For pages where `h2` is wrong. |
| `features` | `TourFeature[]` | `[]` | The steps. See below. A step without a `title` is dropped; with none the section renders nothing at all. The catalog demo's five steps are passed as props, not built in. |
| `initialStep` | `number` | `1` | Which step renders open **before the script measures scroll**. 1-based, clamped. |
| `mediaSide` | `"start" \| "end"` | `"start"` | Which side the media panel takes when pinned. |
| `scrollPerStep` | `number` | `540` | Pixels of page scroll each step owns. Larger is slower. |
| `mediaWidth` | `number` | `60` | Media share of the card, as a percentage. Clamped to 20–80. |
| `pinInset` | `string` | `"40px"` | Viewport inset above and below the pinned card — set it to your fixed header's height. |
| `cardMaxHeight` | `string` | `"720px"` | Tallest the pinned card may get. |
| `accent` | `string` | `"#ff8833"` | Rail fill, links, focus ring, placeholder highlight. |
| `background` | `string` | `"#0a0a0b"` | Ground behind the whole section. |
| `surface` | `string` | `"#141418"` | The card and the open step. |
| `stage` | `string` | `"#0c0c0e"` | The media panel's ground. |
| `border` | `string` | `"#2a2a31"` | Hairlines around the card, panel and steps. |
| `ink` | `string` | `"#ffffff"` | Primary text. |
| `muted` | `string` | `"#a3a3ad"` | Descriptions and secondary text. |
| `radius` | `string` | `"16px"` | Corner radius of the outer card. |
| `maxWidth` | `string` | `"72rem"` | Caps the card width and centres it. |
| `class` | `string` | — | Added to the root `<section>`. |
| `id` | `string` | — | Root element id. Also seeds the step and panel ids. |

`initialStep` is worth reading twice: once the script runs, **scroll position is
the source of truth**, so on a page loaded at the top the first step is active
regardless. The prop decides the server-rendered paint and the no-JS result.

### `TourFeature`

| Field | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `title` | `string` | — | Card title, and the accessible name of its step button. |
| `description` | `string` | — | Revealed when the step is open; always shown when stacked. |
| `image` | `string` | — | Artwork for this step. Omit for the drawn placeholder. |
| `imageAlt` | `string` | `""` | Alternative text. Empty means decorative. |
| `linkLabel` | `string` | — | Call to action under the description. |
| `linkHref` | `string` | — | Destination. **Without one the label renders as plain text, not a link.** |

## Layout and responsive behavior

| Viewport | Mode | What happens |
| :--- | :--- | :--- |
| ≥ 768px, with JS | **pinned** | Card sticks to the viewport; page scroll drives the steps |
| < 768px | **stacked** | Plain list, every step open, artwork inline under each |
| Any width, no JS | **stacked** | Identical to the above |

The breakpoint is a **media query, not a container query** — unlike most
components in this library. That is deliberate: this one pins to the viewport
and sizes itself in `dvh`, so the viewport is the thing it actually responds to.
A container query would let it claim the pinned layout inside a 300px column it
could never pin correctly in.

When pinned, the card is a two-column grid:

| Part | Share |
| :--- | :--- |
| Media panel | `mediaWidth` (default 60%) |
| Step list | the remainder (default 40%) |

`mediaSide="end"` swaps the columns. The gap is 16px, the card padding 16px, and
each step card has a 10px radius inside the card's 16px one.

### The scroll runway

The pinned card consumes real page height. The wrapper around it is:

```
height: 100dvh + (number of steps × scrollPerStep)
```

With the defaults and a 900px viewport that is `900 + 5 × 540 = 3600px`, of
which `3600 − 900 = 2700px` is actual travel — 540px per step. Progress is

```
(distance the runway has scrolled past the top) ÷ travel
```

clamped to 0–1, then multiplied by the step count: the integer part is the open
step, the fraction is that step's rail fill.

**This is the one prop that changes how the page feels.** 540px per step is
brisk. Push it to 800 and each step lingers; drop it to 300 and the tour flicks
past. It also sets how much page the section occupies, so a five-step tour at
800 is 4900px of scroll.

## Interactions

| Action | Result |
| :--- | :--- |
| Scroll | Advances the step, fills the rail, cross-fades the artwork |
| Click a step title | Scrolls the page to the middle of that step's band |
| Arrow Down / Right | Moves focus to the next step and scrolls to it. Wraps. |
| Arrow Up / Left | Previous step. Wraps. |
| Home / End | First / last step |
| Enter or Space | Activates the focused step button |

Scroll handling is `requestAnimationFrame`-throttled with a passive listener, so
it never blocks scrolling, and it does exactly one layout read per frame.

### Animation, measured

| What | Duration | Easing |
| :--- | :--- | :--- |
| Artwork cross-fade and scale (0.8 → 1) | 500ms | `cubic-bezier(0, 0, 0.2, 1)` |
| Rail fill height | 250ms | `ease-out` |
| Step open / close (`grid-template-rows` 0fr → 1fr) | 250ms | `ease-out` |
| Step background and border | 250ms | `ease-out` |
| Link arrow nudge on hover | 200ms | `ease-out` |

## Accessibility

- **Every step title is a real `<button>`**, so the tour is fully operable from
  the keyboard. *The reference's steps are plain `<div>`s with no role and no
  handler — scrolling is the only way to change panel there.*
- **`aria-current="step"`** marks the open one, and each button's
  `aria-controls` points at the artwork it reveals. All ids are per-instance.
- **A collapsed step's description and link leave the tab order.** The body is
  `visibility: hidden` as well as clipped, so Tab never lands on a link nobody
  can see. `transition: visibility 250ms` keeps it on screen for the whole
  collapse and only then hides it.
- **Visible focus**: a 3px accent outline at a 4px offset on `:focus-visible`,
  on both the step buttons and the links.
- **A missing destination never produces a dead link.** A `linkLabel` with no
  `linkHref` renders as a muted `<span>`, not an `<a href="#">`.
- **The heading is optional and levelled.** With no `heading` the component
  emits no header element at all, so it can sit under a heading you already own.
  Step titles are always `<h3>`.
- **Ordered content is an `<ol>`**, because the steps are a sequence.
- **Contrast** on the defaults: titles are `#ffffff` on `#141418` (18.6:1),
  descriptions `#a3a3ad` on `#141418` (8.6:1), links `#ff8833` on `#141418`
  (8.4:1). If you change `accent`, re-check the link and the rail.

### Reduced motion

Under `prefers-reduced-motion: reduce` every transition and animation inside the
component is reduced to `0.01ms`, and the artwork's scale is dropped entirely —
steps change instantly instead of cross-fading and growing. Keyboard jumps use
`behavior: "auto"` instead of `"smooth"`.

**The pin is kept.** Pinning is not motion: nothing travels, the card simply
stays put while the page scrolls. What was moving — the fade, the scale, the
fill, the smooth scroll — is what stops. *The reference respects none of this:
its 500ms cross-fade and 250ms rail both run unchanged under reduce.*

## Multiple instances

**Verified with two on one page**, one three-step and one four-step, with
different accents, grounds, radii, `mediaSide` and `scrollPerStep`:

- Each controller queries only inside its own root; nothing touches the document.
- Ids are prefixed per instance (from `id`, or a random suffix when absent), so
  every `aria-controls` resolves within its own tour and no id is duplicated.
- Parked at the first tour's midpoint: tour A on step 2 at 50% fill, tour B
  untouched at step 1, 0% fill.
- Each reads its own runway, so a page can hold several at different speeds.

## Portability

The component is written to survive a hostile host stylesheet:

- `box-sizing: border-box` is set on every descendant, not inherited.
- `margin`/`padding` are zeroed on the headings, paragraphs, lists and figures it
  owns, and the step button is fully reset (`appearance`, `border`, `background`,
  `font`, `color`).
- `list-style` is removed explicitly, `img` is `display: block; max-width: 100%`.
- `font-size`, `line-height` and `text-align` are set on the root rather than
  inherited, so a host's `body { line-height: 2 }` cannot stretch the card past
  its `cardMaxHeight`.

Everything visual is a custom property on the root:

```css
.my-tour {
  --ft-accent: #38bdf8;
  --ft-bg: #f8fafc;
  --ft-surface: #ffffff;
  --ft-stage: #f1f5f9;
  --ft-border: #e2e8f0;
  --ft-ink: #0f172a;
  --ft-muted: #475569;
}
```

Pass `class="my-tour"` to attach it, or use the equivalent props inline. The
component has no light theme of its own — it is dark by default because the
reference is, but every colour it uses is one of those tokens.

## Differences from the reference

The layout, proportions, pin mechanics, step timing, rail behaviour, cross-fade
easing and responsive swap were measured and reproduced. These are the
deliberate departures:

| | Reference | Here |
| :--- | :--- | :--- |
| Step controls | plain `<div>`, scroll only | `<button>` with `aria-current`, arrows, Home/End |
| Focus | 3 links only | every step, plus links, with a visible ring |
| Collapsed step links | in the tab order while invisible | removed from it |
| Reduced motion | ignored | transitions off, scale off, smooth scroll off |
| Below the breakpoint | a **different element** is rendered | the same element restyles |
| Missing link destination | n/a | renders as text, never a dead anchor |

That fifth row is worth expanding. The reference swaps components at its
breakpoint: `#main-content > section:nth-child(3)` matches one `<section>` at
834px and **nothing at all** at 390px, where a different `<div>` holds the same
content. This component keeps one DOM and restyles it, so the content, ids and
markup are identical at every width — which is what makes the no-JS fallback
free.

Two visual differences worth knowing:

- **The completed steps' rails reset to empty here.** In the reference a
  finished step keeps a small remnant of fill that shrinks as you pass further
  steps. The behaviour was not reproducible from measurement alone and read as
  an artefact rather than an intent.
- **The stacked layout shows a filled accent rail on every card**, since no step
  is "current" when they are all open. The reference has no rail in its mobile
  variant, because its mobile variant is a different component.

## Originality

Every line of markup, CSS and JavaScript here was written for this catalog. No
source, class names, utility framework, wording, product names, screenshots,
icons, fonts, colours-as-brand or other assets were taken from the reference —
which was measured in a browser, not copied. The five demo images are original
CSS scenes generated here, as described above. There is nothing in this
component to attribute and no third-party licence attached to it.

===== END src/components/PinnedFeatureTour.md =====

===== FILE (required): public/pinned-feature-tour/threaded-review-1000.webp =====
[binary asset — download https://astro.baysixmedia.com/pinned-feature-tour/threaded-review-1000.webp and save it to public/pinned-feature-tour/threaded-review-1000.webp]

===== FILE (required): public/pinned-feature-tour/ranked-findings-1000.webp =====
[binary asset — download https://astro.baysixmedia.com/pinned-feature-tour/ranked-findings-1000.webp and save it to public/pinned-feature-tour/ranked-findings-1000.webp]

===== FILE (required): public/pinned-feature-tour/ordered-queue-1000.webp =====
[binary asset — download https://astro.baysixmedia.com/pinned-feature-tour/ordered-queue-1000.webp and save it to public/pinned-feature-tour/ordered-queue-1000.webp]

===== FILE (required): public/pinned-feature-tour/layered-branches-1000.webp =====
[binary asset — download https://astro.baysixmedia.com/pinned-feature-tour/layered-branches-1000.webp and save it to public/pinned-feature-tour/layered-branches-1000.webp]

===== FILE (required): public/pinned-feature-tour/focused-surface-1000.webp =====
[binary asset — download https://astro.baysixmedia.com/pinned-feature-tour/focused-surface-1000.webp and save it to public/pinned-feature-tour/focused-surface-1000.webp]

--- 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
“Pinned feature tour” from my Astro Component Library:
https://astro.baysixmedia.com/components/pinned-feature-tour/

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/PinnedFeatureTour.astro

This component is not one file on its own

  • assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/pinned-feature-tour/threaded-review-1000.webp, public/pinned-feature-tour/ranked-findings-1000.webp, public/pinned-feature-tour/ordered-queue-1000.webp, public/pinned-feature-tour/layered-branches-1000.webp, public/pinned-feature-tour/focused-surface-1000.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
 * Pinned feature tour
 *
 * A feature showcase that pins to the viewport while the page scrolls past it.
 * Each scroll step advances to the next feature: its card expands to reveal a
 * description and link, a progress rail fills alongside it, and the media panel
 * cross-fades to that feature's artwork.
 *
 * Below the pin breakpoint — and whenever JavaScript has not run — the whole
 * thing renders as a plain stacked list with every feature open. That is the
 * fallback, not a degraded mode: all the content is present either way.
 *
 * Self-contained: no imports, no packages, no global stylesheet. Every colour
 * and measurement is a `--ft-*` custom property on the root.
 */

export interface TourFeature {
	/** Card title, and the accessible name of its step button. */
	title: string;
	/** Revealed when the step is active, and always shown when stacked. */
	description?: string;
	/** Artwork for this step. Omit for the built-in drawn placeholder. */
	image?: string;
	/** Alternative text. Empty means decorative. */
	imageAlt?: string;
	/** Optional call to action under the description. */
	linkLabel?: string;
	/** Destination. Without one the label is not rendered as a link. */
	linkHref?: string;
}

interface Props {
	/** Optional heading above the tour. `""` or omitted renders no header. */
	heading?: string;
	/** Optional line under the heading. */
	intro?: string;
	/** For pages where `h2` is the wrong level. */
	headingLevel?: "h1" | "h2" | "h3";
	/** The steps. Two or more is the point. */
	features?: TourFeature[];
	/** Which step is open before any scrolling has happened. 1-based. */
	initialStep?: number;
	/** Which side the media panel takes when pinned. */
	mediaSide?: "start" | "end";
	/** Pixels of page scroll each step owns. Larger is slower. */
	scrollPerStep?: number;
	/** Media / list split when pinned, as a percentage of the card. */
	mediaWidth?: number;
	/** Viewport inset above and below the pinned card, e.g. a fixed header. */
	pinInset?: string;
	/** Tallest the pinned card may get. */
	cardMaxHeight?: string;
	/** Rail fill, links and placeholder highlights. */
	accent?: string;
	/** Ground behind the whole section. */
	background?: string;
	/** The card and active step surface. */
	surface?: string;
	/** The media panel's ground. */
	stage?: string;
	/** Hairlines around the card, the panel and each step. */
	border?: string;
	/** Primary text colour. */
	ink?: string;
	/** Secondary text colour. */
	muted?: string;
	/** Corner radius of the outer card. */
	radius?: string;
	/** Caps the card width and centres it. */
	maxWidth?: string;
	/** Added to the root `<section>`. */
	class?: string;
	/** Root element id. Also seeds the step and panel ids. */
	id?: string;
}

const {
	heading = "",
	intro = "",
	headingLevel = "h2",
	features: featuresProp = [],
	initialStep = 1,
	mediaSide = "start",
	scrollPerStep = 540,
	mediaWidth = 60,
	pinInset = "40px",
	cardMaxHeight = "720px",
	accent = "#ff8833",
	background = "#0a0a0b",
	surface = "#141418",
	stage = "#0c0c0e",
	border = "#2a2a31",
	ink = "#ffffff",
	muted = "#a3a3ad",
	radius = "16px",
	maxWidth = "72rem",
	class: className,
	id,
} = Astro.props;

const Heading = headingLevel;

// A step with no title has no button name, so it is dropped. No steps, no
// section: a heading over an empty runway is not a tour.
const features = featuresProp.filter((feature) => feature && feature.title);

// One id prefix per instance, so several tours can share a page without their
// buttons and panels pointing at each other's elements.
const uid = id ?? `ft-${Math.random().toString(36).slice(2, 9)}`;

const count = Math.max(features.length, 1);
// 1-based in, 0-based out, clamped rather than trusted.
const initial = Math.min(Math.max(Math.trunc(initialStep), 1), count) - 1;

const listWidth = Math.min(Math.max(100 - mediaWidth, 20), 80);

const style = [
	`--ft-accent:${accent}`,
	`--ft-bg:${background}`,
	`--ft-surface:${surface}`,
	`--ft-stage:${stage}`,
	`--ft-border:${border}`,
	`--ft-ink:${ink}`,
	`--ft-muted:${muted}`,
	`--ft-radius:${radius}`,
	`--ft-max:${maxWidth}`,
	`--ft-pin-inset:${pinInset}`,
	`--ft-card-max:${cardMaxHeight}`,
	`--ft-media-w:${Math.min(Math.max(mediaWidth, 20), 80)}fr`,
	`--ft-list-w:${listWidth}fr`,
	`--ft-step:${scrollPerStep}px`,
	`--ft-count:${count}`,
].join(";");
---

{features.length > 0 && (
<section
	class:list={["tour", className]}
	id={id}
	style={style}
	data-ft-root
	data-ft-initial={initial}
>
	{
		(heading || intro) && (
			<header class="head">
				{heading && <Heading class="heading">{heading}</Heading>}
				{intro && <p class="intro">{intro}</p>}
			</header>
		)
	}

	{/* The runway is the scroll distance the pinned card consumes. Its height is
	    the viewport plus one step per feature, which is what turns page scroll
	    into step progress. Stacked, it collapses to auto. */}
	<div class="runway" data-ft-runway>
		<div class="pin" data-ft-pin>
			<div class="card" data-media={mediaSide}>
				{/* Media panel. Every step's artwork is stacked in one grid cell;
				    only the active one is opaque and at full scale. */}
				<div class="stage">
					<span class="dots" aria-hidden="true"></span>
					{
						features.map((feature, i) => (
							<figure
								class="shot"
								id={`${uid}-shot-${i}`}
								data-ft-shot
								data-on={i === initial ? "" : null}
							>
								{feature.image ? (
									<img
										src={feature.image}
										alt={feature.imageAlt ?? ""}
										loading="lazy"
										decoding="async"
									/>
								) : (
									<span class="placeholder" aria-hidden="true">
										<span class="ph-bar"></span>
										<span class="ph-bar"></span>
										<span class="ph-bar"></span>
									</span>
								)}
							</figure>
						))
					}
				</div>

				<ol class="list" data-ft-list>
					{
						features.map((feature, i) => (
							<li class="item" data-ft-item data-on={i === initial ? "" : null}>
								{/* The rail sits outside the button so it can span the whole
								    card while the button's accessible name stays the title. */}
								<span class="rail" aria-hidden="true">
									<span class="fill"></span>
								</span>

								<h3 class="title">
									<button
										type="button"
										class="jump"
										data-ft-jump
										data-index={i}
										aria-controls={`${uid}-shot-${i}`}
										aria-current={i === initial ? "step" : null}
									>
										{feature.title}
									</button>
								</h3>

								<div class="body">
									<div class="body-inner">
										{feature.description && <p class="desc">{feature.description}</p>}
										{feature.linkLabel &&
											(feature.linkHref ? (
												<a class="more" href={feature.linkHref}>
													{feature.linkLabel}
													<span class="arrow" aria-hidden="true">→</span>
												</a>
											) : (
												/* No destination, so no link: a dead anchor that
												   looks clickable is worse than plain text. */
												<span class="more is-static">{feature.linkLabel}</span>
											))}
									</div>
								</div>

								{/* Shown only when stacked, where there is no media panel. */}
								<div class="inline-shot" aria-hidden={feature.image ? null : "true"}>
									{feature.image ? (
										<img
											src={feature.image}
											alt={feature.imageAlt ?? ""}
											loading="lazy"
											decoding="async"
										/>
									) : (
										<span class="placeholder">
											<span class="ph-bar"></span>
											<span class="ph-bar"></span>
											<span class="ph-bar"></span>
										</span>
									)}
								</div>
							</li>
						))
					}
				</ol>
			</div>
		</div>
	</div>
</section>
)}

<style>
	/* --- root and defensive resets ----------------------------------------- */

	.tour {
		box-sizing: border-box;
		padding: clamp(32px, 5vw, 64px) clamp(16px, 4vw, 32px);
		background: var(--ft-bg);
		color: var(--ft-ink);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
		font-size: 16px;
		line-height: 1.5;
		text-align: start;
	}

	/* A destination site's global rules must not be able to move this
	   component's geometry, so every box it owns is reset rather than assumed. */
	.tour *,
	.tour *::before,
	.tour *::after {
		box-sizing: border-box;
	}

	/* `li` is deliberately absent: it has no default padding to reset, and
	   including it would out-specify `.item`'s own padding below. */
	.tour h2,
	.tour h3,
	.tour p,
	.tour figure,
	.tour ol {
		margin: 0;
		padding: 0;
	}

	.tour li {
		margin: 0;
	}

	.tour ol {
		list-style: none;
	}

	.tour img {
		display: block;
		max-width: 100%;
	}

	/* --- header ------------------------------------------------------------- */

	.head {
		max-width: var(--ft-max);
		margin: 0 auto clamp(24px, 3vw, 40px);
	}

	.heading {
		font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem);
		font-weight: 600;
		line-height: 1.15;
		letter-spacing: -0.02em;
	}

	.intro {
		margin-top: 12px;
		max-width: 52ch;
		color: var(--ft-muted);
		font-size: 1.0625rem;
	}

	/* --- stacked: the default, and the no-JS result ------------------------- */

	.runway {
		max-width: var(--ft-max);
		margin-inline: auto;
	}

	.pin {
		display: block;
	}

	.card {
		display: grid;
		gap: 16px;
		padding: 16px;
		border: 1px solid var(--ft-border);
		border-radius: var(--ft-radius);
	}

	.stage {
		display: none;
	}

	.list {
		display: grid;
		gap: 16px;
		min-width: 0;
	}

	.item {
		position: relative;
		padding: 16px 16px 16px 32px;
		border: 1px solid var(--ft-border);
		border-radius: 10px;
		background: var(--ft-surface);
		min-width: 0;
	}

	.rail {
		position: absolute;
		top: 16px;
		bottom: 16px;
		left: 12px;
		width: 4px;
		border-radius: 999px;
		background: var(--ft-border);
		overflow: hidden;
	}

	.fill {
		display: block;
		width: 100%;
		height: 100%;
		border-radius: inherit;
		background: var(--ft-accent);
	}

	.title {
		font-size: 1.25rem;
		font-weight: 500;
		line-height: 1.4;
		letter-spacing: -0.01em;
	}

	/* The button carries no chrome of its own: the card around it is the
	   affordance. It still has to be reset, or a host stylesheet's button rules
	   will repaint it. */
	.jump {
		appearance: none;
		margin: 0;
		padding: 0;
		border: 0;
		background: none;
		color: inherit;
		font: inherit;
		letter-spacing: inherit;
		text-align: inherit;
		cursor: pointer;
	}

	.jump:focus-visible {
		outline: 3px solid var(--ft-accent);
		outline-offset: 4px;
		border-radius: 3px;
	}

	.desc {
		color: var(--ft-muted);
		font-size: 1.0625rem;
		line-height: 1.35;
	}

	.more {
		display: inline-flex;
		align-items: center;
		gap: 0.4em;
		margin-top: 14px;
		color: var(--ft-accent);
		font-size: 1rem;
		text-decoration: none;
	}

	.more:hover .arrow {
		transform: translateX(3px);
	}

	.more:focus-visible {
		outline: 3px solid var(--ft-accent);
		outline-offset: 3px;
		border-radius: 3px;
	}

	.more.is-static {
		color: var(--ft-muted);
		cursor: default;
	}

	.arrow {
		transition: transform 200ms ease-out;
	}

	.body-inner {
		padding-top: 10px;
	}

	.inline-shot {
		margin-top: 18px;
		overflow: hidden;
		aspect-ratio: 4 / 3;
		border: 1px solid var(--ft-border);
		border-radius: 10px;
		background: var(--ft-stage);
	}

	.inline-shot img {
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* Drawn, not loaded: a component with no artwork yet still has to look
	   deliberate rather than broken. */
	.placeholder {
		display: flex;
		flex-direction: column;
		justify-content: center;
		gap: 10px;
		width: 100%;
		height: 100%;
		padding: 14%;
		background:
			radial-gradient(
				circle at 50% 0%,
				color-mix(in srgb, var(--ft-accent) 18%, transparent),
				transparent 70%
			),
			var(--ft-stage);
	}

	.ph-bar {
		display: block;
		height: 10px;
		border-radius: 999px;
		background: color-mix(in srgb, var(--ft-ink) 16%, transparent);
	}

	.ph-bar:first-child {
		width: 62%;
		background: color-mix(in srgb, var(--ft-accent) 55%, transparent);
	}

	.ph-bar:nth-child(2) {
		width: 90%;
	}

	.ph-bar:last-child {
		width: 44%;
	}

	/* --- pinned: only once the script has run, and only when there is room --- */

	@media (min-width: 768px) {
		[data-ft-ready] .runway {
			/* One viewport to pin in, plus one step of scroll per feature. */
			height: calc(100vh + var(--ft-count) * var(--ft-step));
			height: calc(100dvh + var(--ft-count) * var(--ft-step));
		}

		[data-ft-ready] .pin {
			position: sticky;
			top: 0;
			display: flex;
			align-items: center;
			height: 100vh;
			height: 100dvh;
			padding-block: var(--ft-pin-inset);
		}

		[data-ft-ready] .card {
			grid-template-columns: var(--ft-media-w) var(--ft-list-w);
			align-items: stretch;
			width: 100%;
			max-height: var(--ft-card-max);
			height: 100%;
		}

		[data-ft-ready] .card[data-media="end"] {
			grid-template-columns: var(--ft-list-w) var(--ft-media-w);
		}

		[data-ft-ready] .card[data-media="start"] .stage {
			grid-column: 1;
		}

		[data-ft-ready] .card[data-media="start"] .list {
			grid-column: 2;
		}

		[data-ft-ready] .card[data-media="end"] .stage {
			grid-column: 2;
		}

		[data-ft-ready] .card[data-media="end"] .list {
			grid-column: 1;
		}

		[data-ft-ready] .stage {
			position: relative;
			display: grid;
			grid-row: 1;
			overflow: hidden;
			min-width: 0;
			padding: 6%;
			border: 1px solid var(--ft-border);
			border-radius: 10px;
			background: var(--ft-stage);
		}

		[data-ft-ready] .dots {
			position: absolute;
			inset: 0;
			opacity: 0.25;
			background-image: radial-gradient(
				circle,
				color-mix(in srgb, var(--ft-ink) 45%, transparent) 1px,
				transparent 1px
			);
			background-size: 22px 22px;
			pointer-events: none;
		}

		/* Every shot occupies the same cell, so they cross-fade in place
		   instead of pushing each other around. */
		[data-ft-ready] .shot {
			grid-area: 1 / 1;
			display: grid;
			place-items: center;
			min-width: 0;
			opacity: 0;
			transform: scale(0.8);
			transition:
				opacity 500ms cubic-bezier(0, 0, 0.2, 1),
				transform 500ms cubic-bezier(0, 0, 0.2, 1);
			pointer-events: none;
		}

		[data-ft-ready] .shot[data-on] {
			opacity: 1;
			transform: scale(1);
		}

		[data-ft-ready] .shot img,
		[data-ft-ready] .shot .placeholder {
			max-height: 100%;
			width: auto;
			max-width: 100%;
			border-radius: 8px;
		}

		[data-ft-ready] .shot .placeholder {
			width: 100%;
			aspect-ratio: 4 / 3;
		}

		/* Flex, not grid, so the open step can take whatever height the collapsed
		   ones leave — which is what keeps the rail column flush with the media
		   panel beside it at any card height. */
		[data-ft-ready] .list {
			grid-row: 1;
			display: flex;
			flex-direction: column;
			min-height: 0;
		}

		[data-ft-ready] .item {
			flex: 0 0 auto;
			background: transparent;
			overflow: hidden;
			transition:
				background-color 250ms ease-out,
				border-color 250ms ease-out;
		}

		[data-ft-ready] .item[data-on] {
			flex: 1 1 auto;
			min-height: 0;
			background: var(--ft-surface);
		}

		[data-ft-ready] .rail {
			background: var(--ft-border);
		}

		[data-ft-ready] .item[data-on] .rail {
			background: color-mix(in srgb, var(--ft-ink) 20%, transparent);
		}

		/* Height is driven from script as a 0–1 fraction of the step's scroll. */
		[data-ft-ready] .fill {
			height: calc(var(--ft-fill, 0) * 100%);
			transition: height 250ms ease-out;
		}

		[data-ft-ready] .item:not([data-on]) .fill {
			height: 0;
		}

		/* Collapsed steps keep their title and hide the rest, without the
		   description leaving the accessibility tree mid-animation. */
		[data-ft-ready] .body {
			display: grid;
			grid-template-rows: 0fr;
			transition: grid-template-rows 250ms ease-out;
		}

		[data-ft-ready] .item[data-on] .body {
			grid-template-rows: 1fr;
		}

		/* `visibility` and not just `overflow`: a collapsed step's description
		   and link are invisible, so they must leave the tab order and the
		   accessibility tree too, or Tab lands on a link nobody can see.
		   Transitioning visibility keeps it on screen for the whole collapse
		   and only then hides it. */
		[data-ft-ready] .body-inner {
			overflow: hidden;
			padding-top: 0;
			visibility: hidden;
			transition: visibility 250ms;
		}

		[data-ft-ready] .item[data-on] .body-inner {
			padding-top: 10px;
			visibility: visible;
		}

		[data-ft-ready] .inline-shot {
			display: none;
		}
	}

	/* --- reduced motion ------------------------------------------------------ */

	@media (prefers-reduced-motion: reduce) {
		.tour *,
		.tour *::before,
		.tour *::after {
			transition-duration: 0.01ms !important;
			animation-duration: 0.01ms !important;
		}

		/* The cross-fade is the motion, so it goes; the step still changes. */
		[data-ft-ready] .shot {
			transform: none;
		}

		[data-ft-ready] .shot[data-on] {
			transform: none;
		}
	}
</style>

<script>
	/**
	 * One controller per instance. Every element is looked up inside its own
	 * root, so any number of tours can share a page without touching each
	 * other's state.
	 */
	function initPinnedFeatureTour(root: HTMLElement) {
		const runwayEl = root.querySelector<HTMLElement>("[data-ft-runway]");
		const pinEl = root.querySelector<HTMLElement>("[data-ft-pin]");
		const items = Array.from(root.querySelectorAll<HTMLElement>("[data-ft-item]"));
		const shots = Array.from(root.querySelectorAll<HTMLElement>("[data-ft-shot]"));
		const jumps = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ft-jump]"));
		if (!runwayEl || !pinEl || items.length === 0) return;

		// Re-bound after the guard so the narrowing survives into the handlers
		// below, which TypeScript otherwise widens back to "possibly null".
		const runway: HTMLElement = runwayEl;
		const pin: HTMLElement = pinEl;

		const count = items.length;
		let current = Number(root.dataset.ftInitial ?? 0);
		let frame = 0;

		// Turning this on is what enables the pinned layout at all, so the
		// stacked version is what renders if this script never runs.
		root.setAttribute("data-ft-ready", "");

		const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);

		/** Is the pinned layout actually in effect right now? */
		const isPinned = () => getComputedStyle(pin).position === "sticky";

		function paint(index: number, fill: number) {
			if (index !== current) {
				current = index;
				items.forEach((item, i) => {
					if (i === index) item.setAttribute("data-on", "");
					else item.removeAttribute("data-on");
				});
				shots.forEach((shot, i) => {
					if (i === index) shot.setAttribute("data-on", "");
					else shot.removeAttribute("data-on");
				});
				jumps.forEach((jump, i) => {
					if (i === index) jump.setAttribute("aria-current", "step");
					else jump.removeAttribute("aria-current");
				});
			}
			items[index].style.setProperty("--ft-fill", String(fill));
		}

		function measure() {
			if (!isPinned()) {
				// Stacked: the CSS shows everything, so nothing is driven here.
				items.forEach((item) => item.style.removeProperty("--ft-fill"));
				return;
			}
			const travel = runway.offsetHeight - pin.offsetHeight;
			if (travel <= 0) return;
			const scrolled = -runway.getBoundingClientRect().top;
			const progress = clamp(scrolled / travel, 0, 1);
			const raw = progress * count;
			const index = clamp(Math.floor(raw), 0, count - 1);
			paint(index, clamp(raw - index, 0, 1));
		}

		function schedule() {
			if (frame) return;
			frame = requestAnimationFrame(() => {
				frame = 0;
				measure();
			});
		}

		/** Scroll the page to the point where `index` becomes the active step. */
		function goTo(index: number) {
			if (!isPinned()) {
				items[index].scrollIntoView({ block: "center", behavior: "smooth" });
				return;
			}
			const travel = runway.offsetHeight - pin.offsetHeight;
			// Aim at the middle of the step's band, so a rounding error at the
			// boundary cannot land on the neighbour.
			const target =
				window.scrollY +
				runway.getBoundingClientRect().top +
				((index + 0.5) / count) * travel;
			const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
			window.scrollTo({ top: target, behavior: reduce ? "auto" : "smooth" });
		}

		jumps.forEach((jump, i) => {
			jump.addEventListener("click", () => goTo(i));
			jump.addEventListener("keydown", (event) => {
				let next: number;
				switch (event.key) {
					case "ArrowDown":
					case "ArrowRight":
						next = (i + 1) % count;
						break;
					case "ArrowUp":
					case "ArrowLeft":
						next = (i - 1 + count) % count;
						break;
					case "Home":
						next = 0;
						break;
					case "End":
						next = count - 1;
						break;
					default:
						return;
				}
				event.preventDefault();
				jumps[next].focus();
				goTo(next);
			});
		});

		window.addEventListener("scroll", schedule, { passive: true });
		window.addEventListener("resize", schedule, { passive: true });
		measure();
	}

	document
		.querySelectorAll<HTMLElement>("[data-ft-root]")
		.forEach(initPinnedFeatureTour);
</script>