Component library
Back to catalog

Drifting card rail

A full-bleed rail of tall cards that drifts sideways on its own, can be grabbed and flung, and loops forever without a seam.

marqueecarouseldragmedialoopinteractive

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/drifting-card-rail/

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 7 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: "Drifting card rail"
Library page: https://astro.baysixmedia.com/components/drifting-card-rail/
Files: 7 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/DriftingCardRail.astro =====
---
/**
 * DriftingCardRail — a full-bleed rail of tall cards that drifts sideways on
 * its own, can be grabbed and flung, and loops forever without a seam.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts and no network requests of its own: the only things it ever
 * loads are the images you pass in.
 *
 * The decisions that carry the weight, and why they are shaped this way:
 *
 *   - **One transform, not one per card.** The whole track is a single flex
 *     row and the controller moves it with one `translate3d`. The obvious
 *     alternative — absolutely positioning every card and writing a transform
 *     to each one every frame — costs a style recalculation per card per
 *     frame and puts the layout in JavaScript, where a reader cannot see it.
 *     Here the row is ordinary flex layout: the browser computes the
 *     geometry, the controller measures it, and with no script at all the
 *     cards are still a row you can scroll.
 *
 *   - **Velocity is the state, and there is only one of it.** Drift, flinging
 *     and coming to rest are not three behaviours; they are one number
 *     relaxing towards a target. The target is the drift speed while drift is
 *     allowed and zero while it is paused, and a release simply sets the
 *     number to the speed of the gesture. That is why a fling settles back
 *     into the drift instead of stopping dead and starting again, and why
 *     pausing eases off rather than snapping.
 *
 *   - **The loop is real cards, repeated.** The set you pass is measured, and
 *     enough whole copies are appended to cover the rail plus one set, so the
 *     wrap is a modulo on the offset rather than a jump between elements.
 *     Every copy is `aria-hidden`, `inert` and marked `data-dcr-clone`, so the
 *     list a screen reader hears is the list you wrote, once.
 *
 *   - **Motion you did not ask for can be stopped.** The rail carries a real
 *     play/pause button, pauses under the pointer and while focus is inside,
 *     stops when the document is hidden, and does not drift at all under
 *     `prefers-reduced-motion: reduce`. The reference this was rebuilt from
 *     had none of that: no control, no pause, no reduced-motion path, no
 *     keyboard at all.
 *
 *   - **Nothing that cannot work is shown.** Without script the rail is a
 *     horizontally scrollable list of cards — no grab cursor, no tab stop, no
 *     pause button, because a control that does nothing is worse than no
 *     control. The controller adds the affordances when it takes over.
 *
 * Several instances can share a page: every query is scoped to the instance
 * root and all state lives in the controller's closure.
 */
import type { ImageMetadata } from "astro";

export interface DriftingCardRailCard {
	/**
	 * Image for a media card. Its presence is what makes this a media card:
	 * omit it and the entry renders as a text card instead.
	 */
	src?: string | ImageMetadata;
	/** Alternative text. Omitted or empty marks the image decorative. */
	alt?: string;
	/** Text card: the line along the top edge. */
	top?: string;
	/** Text card: the line along the bottom edge. */
	bottom?: string;
	/**
	 * Which of the two lines is set at display size. The other is set small.
	 * Both arrangements are in the reference and they read very differently:
	 * a display line at the top is a title, a display line at the bottom is a
	 * signature.
	 */
	emphasis?: "top" | "bottom";
	/** Text card background. A value that is not a plain colour is ignored. */
	background?: string;
	/** Text card ink. The small line is the same ink, held back. */
	ink?: string;
}

export interface DriftingCardRailProps {
	/** The cards, in order. The set is repeated to fill the rail. */
	cards?: DriftingCardRailCard[];
	/**
	 * Drift speed in pixels per second. Clamped to 0–400; a bad value falls
	 * back to 34, which is what the reference runs at. Zero is a valid
	 * setting and means "only move when dragged".
	 */
	speed?: number;
	/** Which way the cards travel. */
	direction?: "left" | "right";
	/** Accessible name for the rail as a whole. */
	label?: string;
	/** Hold the drift while the pointer is over the rail or focus is inside. */
	pauseOnHover?: boolean;
	/** Render the play/pause control. Only ever shown when drift can run. */
	showPauseControl?: boolean;
	/** Accessible name for that control. It does not change when pressed. */
	pauseLabel?: string;
	/** Optional extra class for project-specific layout. */
	class?: string;
	/** Element id. */
	id?: string;
}

interface Props extends DriftingCardRailProps {}

const {
	cards,
	speed = 34,
	direction = "left",
	label = "Featured cards",
	pauseOnHover = true,
	showPauseControl = true,
	pauseLabel = "Pause the drifting cards",
	class: className,
	id,
}: Props = Astro.props;

/*
 * Numbers are normalised rather than trusted. `speed` reaches the controller
 * through a data attribute and is multiplied by a frame delta, so a NaN would
 * turn the offset into NaN on the first frame and the rail would vanish. The
 * upper bound is a legibility limit rather than a technical one: past a few
 * hundred pixels a second the cards are a blur.
 */
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
	const parsed = typeof value === "number" ? value : Number(value);
	if (!Number.isFinite(parsed)) return fallback;
	return Math.min(max, Math.max(min, parsed));
}

const safeSpeed = clampNumber(speed, 34, 0, 400);
const safeDirection = direction === "right" ? "right" : "left";

/** Control characters are stripped first: "java\tscript:" is a scheme too. */
function stripControls(value: string): string {
	return [...value]
		.filter((character) => {
			const code = character.codePointAt(0) ?? 0;
			return code > 31 && code !== 127;
		})
		.join("");
}

/**
 * Image sources are treated as untrusted: they usually arrive from a CMS or a
 * content collection and become an attribute on every render. Only the schemes
 * that can execute are refused — `data:` is allowed, because an inline SVG is
 * a legitimate image.
 */
function safeSrc(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const bare = stripControls(value.trim()).trim();
	if (bare === "") return undefined;
	if (/^(javascript|vbscript):/i.test(bare)) return undefined;
	return bare;
}

/**
 * Card colours are the one place a caller-supplied string reaches CSS.
 *
 * They are written into an inline `style` as custom properties, so the value
 * is checked rather than escaped: anything carrying a semicolon, a brace, a
 * `url(` or an at-rule could add declarations of its own to that element.
 * What survives is what a colour actually looks like — a hex value, a
 * functional notation, or a bare keyword — and nothing else. A refused value
 * is dropped, so the card renders in the default palette rather than
 * unstyled.
 */
const COLOUR = /^(#[0-9a-f]{3,8}|[a-z]+|(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix)\([^;{}()]*\))$/i;

function safeColour(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const bare = stripControls(value.trim()).trim();
	if (bare === "" || bare.length > 64) return undefined;
	return COLOUR.test(bare) ? bare : undefined;
}

/* Both string paths and imported Astro assets. An asset also carries its
   intrinsic size, which lets the browser reserve the right box. */
function mediaOf(card: DriftingCardRailCard) {
	const metadata = card.src && typeof card.src === "object" ? (card.src as ImageMetadata) : undefined;
	const src = safeSrc(metadata ? metadata.src : card.src);
	if (!src) return undefined;
	return {
		src,
		width: Number.isFinite(metadata?.width) ? metadata!.width : undefined,
		height: Number.isFinite(metadata?.height) ? metadata!.height : undefined,
	};
}

/* No stand-in cards: with none passed, or none that carry an image or a
   line of text, the rail renders nothing. A card with nothing to show is
   dropped rather than drawn as an empty tile. */
const hasText = (value: unknown) => typeof value === "string" && value.trim() !== "";
const list = (Array.isArray(cards) ? cards : []).filter(
	(card) => card && (mediaOf(card) || hasText(card.top) || hasText(card.bottom)),
);

/* Resolved once, in the frontmatter, so the template stays a template: every
   value below has already been through validation. */
const resolved = list.map((card) => {
	const media = mediaOf(card);
	const background = safeColour(card.background);
	const ink = safeColour(card.ink);
	const style = [
		background ? `--dcr-card-bg: ${background};` : "",
		ink ? `--dcr-card-ink: ${ink};` : "",
	]
		.join(" ")
		.trim();
	return {
		media,
		alt: typeof card.alt === "string" ? card.alt : "",
		top: typeof card.top === "string" ? card.top : "",
		bottom: typeof card.bottom === "string" ? card.bottom : "",
		emphasis: card.emphasis === "bottom" ? "bottom" : "top",
		style: style === "" ? undefined : style,
	};
});
---

{resolved.length > 0 && (
<div
	class:list={["drifting-card-rail", className]}
	id={id}
	data-drifting-card-rail
	data-dcr-speed={String(safeSpeed)}
	data-dcr-direction={safeDirection}
	data-dcr-pause-hover={pauseOnHover ? "1" : "0"}
	data-dcr-control={showPauseControl ? "1" : "0"}
	data-dcr-label={label}
>
	{/*
	    A plain div rather than a landmark. The cards are a list and say so;
	    wrapping them in a named region as well would add a second thing to
	    page past for no gain. The controller names the scroller instead, once
	    it can actually be operated.
	*/}
	<div class="dcr-viewport" data-dcr-viewport>
		<ul class="dcr-track" data-dcr-track>
			{resolved.map((card) => (
				<li class="dcr-card" data-dcr-emphasis={card.emphasis} style={card.style}>
					{card.media ? (
						<img
							class="dcr-media"
							src={card.media.src}
							alt={card.alt}
							width={card.media.width}
							height={card.media.height}
							draggable="false"
							decoding="async"
						/>
					) : (
						/*
						 * Paragraphs, not headings. These are display lines on a
						 * decorative rail, and a component that injected h2s into
						 * somebody else's page would corrupt their outline for the
						 * sake of looking like a title.
						 */
						<div class="dcr-copy">
							<p class="dcr-line dcr-line--top">{card.top}</p>
							<p class="dcr-line dcr-line--bottom">{card.bottom}</p>
						</div>
					)}
				</li>
			))}
		</ul>
	</div>

	{showPauseControl && (
		<button class="dcr-toggle" type="button" data-dcr-toggle hidden aria-pressed="false">
			<span class="dcr-toggle-label">{pauseLabel}</span>
			<svg class="dcr-glyph dcr-glyph--pause" aria-hidden="true" viewBox="0 0 24 24" width="18" height="18">
				<path d="M9 5v14M15 5v14" />
			</svg>
			<svg class="dcr-glyph dcr-glyph--play" aria-hidden="true" viewBox="0 0 24 24" width="18" height="18">
				<path d="M8 5.5v13l11-6.5z" />
			</svg>
		</button>
	)}
</div>
)}

<script>
	/**
	 * DriftingCardRail controller.
	 *
	 * One closure per instance. Every query is scoped to that instance's root,
	 * every listener carries an AbortController signal, and the whole thing is
	 * torn down before a client-side navigation swaps the document.
	 *
	 * The animation frame is not left running when there is nothing to do. A
	 * paused rail cancels its own loop and a resumed one starts a new frame,
	 * so an idle instance costs nothing and a torn-down one leaves no callback
	 * behind for the lifecycle probe to find.
	 */
	interface Instance {
		destroy(): void;
	}

	interface Tween {
		from: number;
		to: number;
		start: number;
		duration: number;
	}

	function initDriftingCardRail(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.dcrReady === "") return undefined;

		const viewportQuery = root.querySelector<HTMLElement>("[data-dcr-viewport]");
		const trackQuery = root.querySelector<HTMLElement>("[data-dcr-track]");
		if (!viewportQuery || !trackQuery) return undefined;

		const viewport = viewportQuery;
		const track = trackQuery;
		const originals = Array.from(track.children) as HTMLElement[];
		if (originals.length === 0) return undefined;

		const toggle = root.querySelector<HTMLButtonElement>("[data-dcr-toggle]");

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

		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
		const speed = Number(root.dataset.dcrSpeed) || 0;
		const heading = root.dataset.dcrDirection === "right" ? 1 : -1;
		const pauseOnHover = root.dataset.dcrPauseHover !== "0";
		const label = root.dataset.dcrLabel || "Featured cards";

		/*
		 * How much of a velocity's excess over the drift survives one second.
		 *
		 * Measured off the reference rather than chosen: sampling a released
		 * fling every 200ms, the amount by which the velocity exceeded the
		 * drift fell by a constant 0.628 each time, which is 0.098 over a
		 * second. Applied as a power of the frame delta rather than per frame,
		 * so a 144Hz display and a 60Hz one come to rest together.
		 */
		const RELAX_PER_SECOND = 0.098;
		/* Past this a flick is a throw. The reference has no limit and a
		   trackpad flick can leave the cards unreadable for a second. */
		const MAX_FLING = 4000;
		/* Below this a press is a click rather than a drag. */
		const THRESHOLD = 8;

		/* ---- geometry ------------------------------------------------- */

		let span = 0; /* one full set of cards, including the trailing gap */
		let pitch = 0; /* one card plus one gap */

		function measure() {
			const width = originals[0].getBoundingClientRect().width;
			const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
			pitch = width + gap;
			span = pitch * originals.length;
		}

		function clearClones() {
			for (const clone of Array.from(track.querySelectorAll("[data-dcr-clone]"))) {
				clone.remove();
			}
		}

		/**
		 * Repeat the set until it covers the rail plus one whole set.
		 *
		 * That is what lets the wrap be a modulo: at any offset within one
		 * span there is always a full set of cards to the right of the fold,
		 * so the row can be snapped back by exactly one span without anything
		 * visibly moving.
		 *
		 * The copies are `aria-hidden` and `inert`, so the list stays the
		 * length the author wrote and nothing inside a copy can take focus.
		 */
		function fill() {
			clearClones();
			measure();
			if (span <= 0) return;
			const needed = Math.ceil(viewport.getBoundingClientRect().width / span) + 1;
			const fragment = document.createDocumentFragment();
			for (let set = 1; set < needed; set += 1) {
				for (const card of originals) {
					const copy = card.cloneNode(true) as HTMLElement;
					copy.dataset.dcrClone = "";
					copy.setAttribute("aria-hidden", "true");
					copy.setAttribute("inert", "");
					fragment.appendChild(copy);
				}
			}
			track.appendChild(fragment);
		}

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

		let offset = 0;
		let velocity = 0;
		let frameId: number | undefined;
		let lastFrame = 0;
		let tween: Tween | null = null;

		let hovering = false;
		let focused = false;
		let manuallyPaused = false;
		let dragging = false;

		const driftVelocity = () => heading * speed;

		function driftAllowed(): boolean {
			if (speed <= 0 || reduced.matches) return false;
			if (manuallyPaused || document.hidden) return false;
			if (pauseOnHover && (hovering || focused)) return false;
			return true;
		}

		function apply() {
			if (span <= 0) return;
			let x = offset % span;
			if (x > 0) x -= span;
			track.style.transform = `translate3d(${x}px, 0, 0)`;
		}

		/* Ease-out cubic. Only the keyboard step uses it; everything else is
		   the velocity model, which has an easing of its own by construction. */
		const ease = (t: number) => 1 - Math.pow(1 - t, 3);

		function frame(now: number) {
			frameId = window.requestAnimationFrame(frame);
			const delta = Math.min(0.05, (now - lastFrame) / 1000);
			lastFrame = now;
			if (dragging || span <= 0) return;

			if (tween) {
				const progress = Math.min(1, (now - tween.start) / tween.duration);
				offset = tween.from + (tween.to - tween.from) * ease(progress);
				if (progress >= 1) tween = null;
				apply();
				return;
			}

			const target = driftAllowed() ? driftVelocity() : 0;
			velocity = target + (velocity - target) * Math.pow(RELAX_PER_SECOND, delta);
			/* Half a pixel a second is not motion. Snapping the last of it away
			   is what lets a paused rail stop its own frame loop. */
			if (Math.abs(velocity - target) < 0.5) velocity = target;
			if (velocity === 0) {
				stopFrames();
				return;
			}
			offset += velocity * delta;
			offset %= span;
			apply();
		}

		function startFrames() {
			if (frameId !== undefined) return;
			lastFrame = performance.now();
			frameId = window.requestAnimationFrame(frame);
		}

		function stopFrames() {
			if (frameId === undefined) return;
			window.cancelAnimationFrame(frameId);
			frameId = undefined;
		}

		/** Whatever changed, work out whether the loop should be running. */
		function refresh() {
			syncToggle();
			if (driftAllowed() || velocity !== 0 || tween) startFrames();
		}

		/* ---- the pause control ---------------------------------------- */

		/* Shown only where it can do something. Under reduced motion, or with
		   the speed at zero, there is no drift to pause. */
		const controlWanted = root.dataset.dcrControl !== "0";

		function syncToggle() {
			if (!toggle) return;
			const usable = controlWanted && speed > 0 && !reduced.matches;
			toggle.hidden = !usable;
			toggle.setAttribute("aria-pressed", manuallyPaused ? "true" : "false");
		}

		toggle?.addEventListener(
			"click",
			() => {
				manuallyPaused = !manuallyPaused;
				refresh();
			},
			{ signal },
		);

		/* ---- pointer dragging ------------------------------------------ */

		let pointerId: number | null = null;
		let startX = 0;
		let startY = 0;
		let lastX = 0;
		let lastTime = 0;
		let moved = false;
		let gestureVelocity = 0;
		/* Armed by a committed drag so the click it produces is not also
		   followed. It disarms itself; see endDrag. */
		let suppressClick = false;
		let suppressTimer: number | undefined;

		viewport.addEventListener(
			"pointerdown",
			(event) => {
				if (event.button !== 0) return;
				pointerId = event.pointerId;
				startX = lastX = event.clientX;
				startY = event.clientY;
				lastTime = event.timeStamp;
				moved = false;
				gestureVelocity = 0;
				suppressClick = false;
				window.clearTimeout(suppressTimer);
			},
			{ signal },
		);

		/*
		 * The rest of the gesture is tracked on the window rather than on the
		 * rail, and without pointer capture.
		 *
		 * A drag that carries the pointer off the rail — which is most of
		 * them, on a rail that is only as tall as one card — otherwise stops
		 * producing events the moment the pointer leaves, and the `pointerup`
		 * that ends the drag never arrives. Capture was supposed to prevent
		 * that and does not hold for touch pointers in this library's
		 * experience, so the window is the reliable place to listen.
		 */
		window.addEventListener(
			"pointermove",
			(event) => {
				if (pointerId === null || event.pointerId !== pointerId) return;
				const dx = event.clientX - startX;
				const dy = event.clientY - startY;

				if (!moved) {
					if (Math.abs(dx) < THRESHOLD) return;
					/* A mostly-vertical gesture belongs to the page, not to us. */
					if (Math.abs(dy) > Math.abs(dx)) {
						pointerId = null;
						return;
					}
					moved = true;
					dragging = true;
					tween = null;
					root.dataset.dcrDragging = "";
					stopFrames();
				}

				const step = event.clientX - lastX;
				const elapsed = Math.max(1, event.timeStamp - lastTime) / 1000;
				lastX = event.clientX;
				lastTime = event.timeStamp;
				offset += step;
				/* Smoothed, so one stuttering frame at the end of a gesture
				   cannot decide how hard the rail was thrown. */
				gestureVelocity = gestureVelocity * 0.72 + (step / elapsed) * 0.28;
				apply();
			},
			{ signal },
		);

		function endDrag() {
			if (pointerId === null) return;
			pointerId = null;
			if (!moved) return;
			moved = false;
			dragging = false;
			delete root.dataset.dcrDragging;

			/*
			 * A mouse drag is followed by a click on whatever sat under the
			 * pointer, so that click has to be swallowed. A touch swipe is not
			 * followed by one at all — so the suppressor cannot wait for a
			 * click to clear it, or the next click to reach the rail is eaten
			 * instead. It expires on its own.
			 */
			suppressClick = true;
			window.clearTimeout(suppressTimer);
			suppressTimer = window.setTimeout(() => {
				suppressClick = false;
			}, 400);

			/*
			 * Under reduced motion a release stops where it was let go. The
			 * drift is suppressed there, and continuing to move after the
			 * gesture has ended is exactly the kind of motion that preference
			 * is asking not to see.
			 */
			velocity = reduced.matches
				? 0
				: Math.max(-MAX_FLING, Math.min(MAX_FLING, gestureVelocity));
			refresh();
		}

		window.addEventListener("pointerup", endDrag, { signal });
		window.addEventListener("pointercancel", endDrag, { signal });
		/* A touch the browser takes over for scrolling ends the drag too. */
		window.addEventListener("touchcancel", endDrag, { signal });

		viewport.addEventListener(
			"click",
			(event) => {
				if (!suppressClick) return;
				suppressClick = false;
				window.clearTimeout(suppressTimer);
				event.preventDefault();
				event.stopPropagation();
			},
			{ capture: true, signal },
		);

		viewport.addEventListener("dragstart", (event) => event.preventDefault(), { signal });

		/* ---- keyboard --------------------------------------------------- */

		/*
		 * The keyboard equivalent of a drag, and the reason the rail is worth
		 * making focusable at all: without it the only way to see the cards
		 * behind you is to wait for them to come round again, which is not an
		 * interface. One press moves the rail by exactly one card.
		 */
		viewport.addEventListener(
			"keydown",
			(event) => {
				if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
				if (event.altKey || event.ctrlKey || event.metaKey) return;
				event.preventDefault();
				const towards = event.key === "ArrowRight" ? -1 : 1;
				const destination = offset + towards * pitch;
				if (reduced.matches) {
					offset = destination;
					apply();
					return;
				}
				tween = { from: offset, to: destination, start: performance.now(), duration: 420 };
				startFrames();
			},
			{ signal },
		);

		/* ---- pausing ---------------------------------------------------- */

		/*
		 * Hover is a mouse affordance. `pointerenter` fires for a touch tap
		 * too, and a rail that paused for the rest of the session because
		 * somebody once tapped it would look broken.
		 */
		viewport.addEventListener(
			"pointerenter",
			(event) => {
				if (event.pointerType === "touch") return;
				hovering = true;
				refresh();
			},
			{ signal },
		);

		viewport.addEventListener(
			"pointerleave",
			(event) => {
				if (event.pointerType === "touch") return;
				hovering = false;
				refresh();
			},
			{ signal },
		);

		/**
		 * Whether someone is reading this rail with the keyboard.
		 *
		 * `:focus-visible` rather than plain focus, and the distinction is the
		 * whole point. The scroller is focusable, so a mouse click anywhere on
		 * the rail — including the click at the end of every drag — leaves
		 * focus sitting inside it. Treating that as "someone is working here"
		 * pauses the drift for the rest of the session: the rail stops the
		 * first time it is dragged and never starts again, which reads as a
		 * component that broke rather than one that paused.
		 */
		function keyboardFocusInside(target: Element | null): boolean {
			if (!target) return false;
			try {
				return target.matches(":focus-visible");
			} catch {
				/* No :focus-visible support: keep the safe answer, which is to
				   hold the motion rather than move under someone's hands. */
				return true;
			}
		}

		root.addEventListener("focusin", (event) => {
			focused = keyboardFocusInside(event.target as Element | null);
			refresh();
		}, { signal });

		root.addEventListener("focusout", (event) => {
			if (root.contains(event.relatedTarget as Node | null)) return;
			focused = false;
			refresh();
		}, { signal });

		document.addEventListener("visibilitychange", refresh, { signal });
		reduced.addEventListener("change", refresh, { signal });

		/* ---- resize ------------------------------------------------------ */

		/*
		 * The card width is a container query away from the rail's own width,
		 * so a resize changes the span, the pitch and how many copies are
		 * needed. Rebuilt on an animation frame rather than per observer
		 * callback, because a drag of a window edge fires a great many of
		 * those and each rebuild touches the DOM.
		 */
		let resizePending = false;
		const observer = new ResizeObserver(() => {
			if (resizePending) return;
			resizePending = true;
			window.requestAnimationFrame(() => {
				resizePending = false;
				fill();
				apply();
			});
		});
		observer.observe(viewport);

		/* ---- boot --------------------------------------------------------- */

		/*
		 * The scroller becomes a control only now that there is something
		 * behind it. Served without these, it is an ordinary overflow box that
		 * scrolls with no script at all — and nothing announces a keyboard
		 * shortcut that does not exist.
		 */
		viewport.scrollLeft = 0;
		viewport.setAttribute("tabindex", "0");
		viewport.setAttribute("role", "group");
		viewport.setAttribute("aria-label", label);
		viewport.setAttribute("aria-keyshortcuts", "ArrowLeft ArrowRight");

		fill();
		apply();
		refresh();

		function destroy() {
			/* Back to exactly the state the markup was served in, so a re-used
			   element cannot inherit a running rail from the previous route. */
			delete root.dataset.dcrReady;
			delete root.dataset.dcrDragging;
			stopFrames();
			window.clearTimeout(suppressTimer);
			observer.disconnect();
			clearClones();
			track.style.transform = "";
			viewport.removeAttribute("tabindex");
			viewport.removeAttribute("role");
			viewport.removeAttribute("aria-label");
			viewport.removeAttribute("aria-keyshortcuts");
			if (toggle) {
				toggle.hidden = true;
				toggle.setAttribute("aria-pressed", "false");
			}
			offset = 0;
			velocity = 0;
			tween = null;
			pointerId = null;
			dragging = false;
			manuallyPaused = false;
			controller.abort();
		}

		/*
		 * Astro replaces the document on a client-side navigation. Detach every
		 * listener, return the rail to its served state, and clear the ready
		 * flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

	const boot = () =>
		document
			.querySelectorAll<HTMLElement>("[data-drifting-card-rail]")
			.forEach(initDriftingCardRail);

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.drifting-card-rail {
		/* ---- overridable tokens -------------------------------------------
		   Set any of these on the element, from a class, an id or a wrapper
		   rule, to retune the component without touching this file. */

		/*
		 * The reference is 400px wide at every viewport it has, including a
		 * 320px phone, where the card is wider than the screen and can never
		 * be seen whole. This keeps the 400px ceiling and lets the card fall
		 * back to a share of the rail below it.
		 */
		--dcr-card-width: clamp(13.75rem, 74cqi, 25rem);
		/* 400 x 550 in the reference, which is exactly 8:11. */
		--dcr-ratio: 8 / 11;
		--dcr-gap: clamp(0.75rem, 1.7cqi, 1.5rem);
		--dcr-radius: clamp(0.5rem, calc(var(--dcr-card-width) * 0.035), 0.875rem);
		/*
		 * Breathing room above and below the cards, and the reason the floor is
		 * 2.75rem rather than something smaller: the pause control is 44px, and
		 * below that the rail would put it on top of a card on a phone.
		 *
		 * It is NOT what keeps the shadow off the edge. Measured on a rendered
		 * card, `0 26px 54px -18px` is still darker than the page 70px below it
		 * — twice what the arithmetic suggests, because Chromium's blur reaches
		 * about a full blur radius past the shadow box rather than half of one.
		 * Reserving that much empty space on every rail would be absurd, so the
		 * vertical clip is removed instead; see `.dcr-viewport`.
		 */
		--dcr-inset: clamp(2.75rem, 5cqi, 3.5rem);
		--dcr-surface: #e8e3da;
		--dcr-shadow: 0 26px 54px -18px rgb(0 0 0 / 0.55);
		/* The zoom the media takes under the pointer, and the curve it takes
		   to get there. Both measured off the reference. */
		--dcr-zoom: 1.02;
		--dcr-zoom-duration: 700ms;
		--dcr-zoom-ease: cubic-bezier(0.19, 0.72, 0.22, 1);
		--dcr-card-bg: #24211d;
		--dcr-card-ink: #f2f0ec;
		--dcr-ring: #1b1a18;

		box-sizing: border-box;
		/* Queried by the card width above, so the rail responds to the column
		   it is dropped into rather than to the viewport. */
		container-type: inline-size;
		position: relative;
		display: block;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.drifting-card-rail :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	/*
	 * Served as an ordinary scroll container, which is the whole no-script
	 * story: the cards are a row, the row scrolls, and a pointer or a keyboard
	 * can reach every one of them with nothing running. The controller takes
	 * the scrolling away only once it can move the row itself.
	 */
	.dcr-viewport {
		/* On the scroller, not on the rail around it. Padding out here would
		   look the same at rest and still leave the shadow cut off, because
		   the clip happens at this element's box. */
		padding-block: var(--dcr-inset);
		overflow-x: auto;
		overflow-y: hidden;
		/* Vertical page scrolling is never ours to take. */
		touch-action: pan-y;
		-webkit-overflow-scrolling: touch;
	}

	.drifting-card-rail[data-dcr-ready] .dcr-viewport {
		overflow: hidden;
		cursor: grab;
		user-select: none;
		-webkit-user-select: none;
	}

	/*
	 * The rail has to clip sideways — that is what hides the wrap — and it must
	 * not clip downwards, or it cuts its own drop shadow off at a hard
	 * horizontal line under every card.
	 *
	 * `overflow: hidden` cannot do one axis and not the other: a `visible`
	 * opposite axis computes to `auto`, which would put a scrollbar on a
	 * surface the controller is already moving by hand. `clip` has no such
	 * rule, and that is the only reason it is here. The rule above stays as the
	 * fallback, where the shadow is clipped at the inset rather than at the
	 * card's edge — a soft step of about four levels out of 255, against the
	 * hard line the card edge produced.
	 */
	@supports (overflow: clip) {
		.drifting-card-rail[data-dcr-ready] .dcr-viewport {
			overflow-x: clip;
			overflow-y: visible;
		}
	}

	.drifting-card-rail[data-dcr-dragging] .dcr-viewport {
		cursor: grabbing;
	}

	.dcr-viewport:focus-visible {
		/* Drawn inside, so the rail's own clipping cannot cut the ring off. */
		outline: 3px solid var(--dcr-ring);
		outline-offset: -3px;
	}

	.dcr-track {
		display: flex;
		flex-wrap: nowrap;
		gap: var(--dcr-gap);
		width: max-content;
		margin: 0;
		/* The first card sits a gap in from the edge, so a card at rest never
		   looks glued to the side of the page. */
		padding: 0 var(--dcr-gap);
		list-style: none;
		will-change: transform;
	}

	.dcr-card {
		position: relative;
		flex: none;
		width: var(--dcr-card-width);
		aspect-ratio: var(--dcr-ratio);
		border-radius: var(--dcr-radius);
		overflow: hidden;
		background: var(--dcr-surface);
		box-shadow: var(--dcr-shadow);
		/* So the copy inside scales with the card rather than with the rail. */
		container-type: inline-size;
	}

	.dcr-media {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: center;
		transition: transform var(--dcr-zoom-duration) var(--dcr-zoom-ease);
	}

	.dcr-card:hover .dcr-media {
		transform: scale(var(--dcr-zoom));
	}

	/*
	 * While the rail is being dragged the zoom is off, transition and all. A
	 * card sliding out from under the pointer would otherwise hand the zoom to
	 * the next card along, and the rail would ripple as it moved.
	 */
	.drifting-card-rail[data-dcr-dragging] .dcr-media {
		transition: none;
		transform: none;
	}

	/* ---- text cards ----------------------------------------------------- */

	.dcr-copy {
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		gap: 3.5cqi;
		width: 100%;
		height: 100%;
		padding: 6.5cqi;
		background: var(--dcr-card-bg);
	}

	.dcr-line {
		margin: 0;
		color: var(--dcr-card-ink);
		text-wrap: pretty;
		overflow-wrap: anywhere;
	}

	/*
	 * One line is display size and the other is not, and which is which is the
	 * card's own decision. Both arrangements are in the reference: a display
	 * line at the top reads as a title, the same line at the bottom reads as a
	 * signature under a caption.
	 */
	.dcr-card[data-dcr-emphasis="top"] .dcr-line--top,
	.dcr-card[data-dcr-emphasis="bottom"] .dcr-line--bottom {
		font: 400 17.5cqi / 1 Georgia, "Times New Roman", serif;
		letter-spacing: -0.015em;
	}

	.dcr-card[data-dcr-emphasis="top"] .dcr-line--bottom,
	.dcr-card[data-dcr-emphasis="bottom"] .dcr-line--top {
		font: 400 5.5cqi / 1.1 system-ui, sans-serif;
		letter-spacing: -0.02em;
		/* The quieter of the two, in the same ink rather than a second colour:
		   one colour per card is one thing to get right. */
		opacity: 0.74;
	}

	/* An empty line still holds its end of the card, so a one-line text card
	   keeps its copy where the design put it rather than centring it. */
	.dcr-line:empty::after {
		content: "";
		display: block;
	}

	/* ---- the pause control ---------------------------------------------- */

	.dcr-toggle {
		position: absolute;
		right: clamp(0.75rem, 2cqi, 1.5rem);
		bottom: clamp(0.75rem, 2cqi, 1.5rem);
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 44px;
		height: 44px;
		margin: 0;
		padding: 0;
		border: 1px solid rgb(0 0 0 / 0.14);
		border-radius: 999px;
		background: rgb(255 255 255 / 0.86);
		color: #1b1a18;
		/* Lifts it off whatever is behind. The rail's vertical inset clears the
		   cards at desktop sizes but not on a phone, where the inset is smaller
		   than a 44px target — so the control has to read as a control over a
		   photograph as well as over the page. */
		box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
		cursor: pointer;
		-webkit-tap-highlight-color: transparent;
	}

	/* Beats the display rule above, so a control the controller has not
	   revealed stays gone rather than merely transparent. */
	.dcr-toggle[hidden] {
		display: none;
	}

	.dcr-toggle:focus-visible {
		outline: 3px solid var(--dcr-ring);
		outline-offset: 2px;
	}

	/* The name is on the button and reaches assistive technology; the glyph is
	   what the eye gets. */
	.dcr-toggle-label {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	.dcr-glyph {
		flex: none;
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.dcr-glyph--play {
		fill: currentColor;
		stroke: none;
	}

	/* Pressed means "the drift is paused", so the control offers play. */
	.dcr-toggle[aria-pressed="false"] .dcr-glyph--play,
	.dcr-toggle[aria-pressed="true"] .dcr-glyph--pause {
		display: none;
	}

	/* ---- reduced motion --------------------------------------------------
	   The drift and the fling are suppressed in the controller, which is the
	   only place they could be: they are a number, not a transition. What is
	   left here is the hover zoom, which is a transition and is dropped
	   outright rather than made instant — a 0ms scale is a jump, and a jump is
	   more motion than no zoom at all. */
	@media (prefers-reduced-motion: reduce) {
		.dcr-media {
			transition: none;
		}

		.dcr-card:hover .dcr-media {
			transform: none;
		}

		.dcr-track {
			will-change: auto;
		}
	}
</style>

===== END src/components/DriftingCardRail.astro =====

===== FILE (supporting): src/components/DriftingCardRail.md =====
# Drifting card rail

A full-bleed rail of tall cards that drifts sideways on its own. Grab it and it
follows the pointer; let go and it carries the throw, easing back into the
drift. The set repeats, so it never reaches an end.

Catalog page: `/components/drifting-card-rail`

## Purpose

For a band of work, product shots or moments that should feel continuous rather
than paginated — a studio index, a lookbook, a "what we made this year" strip.
There is no active card, no dots and nothing to arrive at, which is the point:
it reads as a surface passing by rather than a thing you are expected to
operate.

Use something else if the reader has to compare the cards, return to a
particular one, or act on them. A rail that never stops is a poor place to put
a call to action.

## Files required

**`DriftingCardRail.astro`** — one file, the whole component.

- No imports except `import type { ImageMetadata } from "astro"`, which is a
  type and disappears at build time.
- No npm packages, no animation library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size and duration is declared on the
  component's own root element as a `--dcr-*` custom property, so it inherits
  nothing from the host page and leaks nothing back.
- No webfonts and **no network requests of its own**. The only things it loads
  are the images you pass in `cards`.
- The only assumption about the host is that Astro compiles `.astro` files.

`DriftingCardRail.md` — this file. Documentation, not required to run.

Whatever images you pass travel with it. The catalog demo uses six SVGs in
`public/drifting-card-rail/`.

## Basic usage

```astro
---
import DriftingCardRail from "../components/DriftingCardRail.astro";
import dune from "../assets/dune.jpg";
---

<DriftingCardRail
  label="Recent work"
  speed={34}
  cards={[
    { src: dune, alt: "A low sun over layered dunes" },
    { src: "/images/tide.jpg", alt: "A pale horizon band across deep water" },
    {
      top: "Work that keeps moving",
      bottom: "Eight pieces from the last year.",
      emphasis: "top",
      background: "#1f3a2b",
      ink: "#eef2e6",
    },
  ]}
/>
```

No client directive, no wrapper, no init call — the component ships its own
module script and boots itself.

It is full-bleed by design: give it the page's whole width. Inside a narrower
column it still works, because the card width is a container query against the
rail rather than the viewport, but a rail with only one card visible is a
carousel with the interesting part cut off.

## Props

Content has no default. A rail with no `cards` — or none carrying an image or
a line of text — renders nothing. A card with nothing to show is dropped.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `cards` | `DriftingCardRailCard[]?` | `[]` | The cards, in order. The set is repeated to fill the rail. |
| `speed` | `number?` | `34` | Drift in pixels per second. Clamped to `0`–`400`; a `NaN` or `Infinity` falls back to `34`. `0` means "only moves when dragged". |
| `direction` | `"left" \| "right"?` | `"left"` | Which way the cards travel. |
| `label` | `string?` | `"Featured cards"` | Accessible name for the rail. |
| `pauseOnHover` | `boolean?` | `true` | Hold the drift under the pointer and while a keyboard user is inside it. |
| `showPauseControl` | `boolean?` | `true` | Render the play/pause button. It is only ever shown when the drift can actually run. |
| `pauseLabel` | `string?` | `"Pause the drifting cards"` | Accessible name for that button. It does not change when pressed; `aria-pressed` carries the state. |
| `class` | `string?` | — | Extra class on the root, for project-specific layout. |
| `id` | `string?` | — | Element id. |

### A card

| Field | Type | What it does |
| :--- | :--- | :--- |
| `src` | `string \| ImageMetadata?` | Image. **Its presence is what makes a card a media card.** A `javascript:` or `vbscript:` value renders no image at all. |
| `alt` | `string?` | Alternative text. Omitted or empty marks the image decorative. |
| `top` | `string?` | Text card: the line along the top edge. |
| `bottom` | `string?` | Text card: the line along the bottom edge. |
| `emphasis` | `"top" \| "bottom"?` | Which line is set at display size. The other is set small and held back. Defaults to `"top"`. |
| `background` | `string?` | Text card background. Anything that is not a plain colour is ignored. |
| `ink` | `string?` | Text card ink, used for both lines. |

```ts
import type { DriftingCardRailProps, DriftingCardRailCard } from "../components/DriftingCardRail.astro";
```

### No slots

Deliberately. A slot would put arbitrary markup inside an element that is
cloned to fill the rail, and a clone of a focusable control is a duplicate tab
stop wearing the same accessible name. Cards are data here so the component can
guarantee what the copies contain.

## Behaviour

| Input | What happens |
| :--- | :--- |
| Nothing | The rail drifts at `speed`, forever, in `direction`. |
| Pointer over the rail | The drift eases to a stop, and eases back when the pointer leaves. |
| Drag | The rail follows the pointer 1:1. |
| Release | The rail carries the speed of the gesture and relaxes back into the drift. |
| A click that was a drag | Swallowed, so a drag that ends on a card cannot also act on it. |
| A click that was a click | Passes through normally. |
| Touch swipe | The same as a drag. A mostly-vertical gesture is left to the page. |
| Tap | Does not pause anything. Hover is a mouse affordance. |
| <kbd>←</kbd> / <kbd>→</kbd> with the rail focused | Steps exactly one card, and holds the drift while focus stays inside. |
| The pause button | Holds the drift until pressed again. `aria-pressed` carries the state. |
| Switching tabs | The drift stops and resumes with the document. |
| Mouse wheel | Nothing. The rail never takes the page's scrolling. |

### One number, not three behaviours

Drift, flinging and coming to rest are the same mechanism: a velocity relaxing
towards a target. The target is the drift speed while drift is allowed and zero
while it is paused; a release simply sets the velocity to the speed of the
gesture.

That is why a fling settles back into the drift rather than stopping dead and
starting again, and why pausing eases off instead of snapping. The relaxation
is **10% of the excess left after one second**, applied as a power of the frame
delta so a 144Hz display and a 60Hz one come to rest together.

### The loop

The card set is measured, and enough whole copies are appended to cover the
rail plus one full set. The wrap is then a modulo on the offset: the row is
snapped back by exactly one set width, and since the content at that distance
is identical, nothing visibly moves.

Copies carry `aria-hidden`, `inert` and `data-dcr-clone`. The list a screen
reader hears is the list you wrote, once.

### Frames are not left running

A paused rail cancels its animation frame and a resumed one starts a new one,
so an idle instance costs nothing and a torn-down one leaves no callback
behind.

## Accessibility

- The cards are a `<ul>`. The copies are out of it as far as assistive
  technology is concerned, so the count is the count you wrote.
- The scroller is given `tabindex="0"`, `role="group"`, the `label` as its
  accessible name, and `aria-keyshortcuts="ArrowLeft ArrowRight"` — **by the
  controller, at boot**. Without script there is no tab stop, because there
  would be nothing behind it.
- <kbd>←</kbd> and <kbd>→</kbd> step one card. This is the keyboard equivalent
  of the drag, and the reason the rail is focusable at all: without it the only
  way to see the cards behind you is to wait for them to come round.
- Focus inside the rail holds the drift, so a keyboard user is not reading a
  moving target. **`:focus-visible`, not focus** — the scroller is focusable,
  so every mouse click leaves focus inside it, and treating that as "someone is
  working here" would stop the rail the first time it was dragged and never
  start it again.
- The play/pause button is a real `<button>` with `aria-pressed`, at least
  44×44, and it is the mechanism WCAG 2.2.2 asks for. It is hidden when there
  is no drift to pause — `speed: 0`, or reduced motion.
- Focus rings are drawn inside the rail, so its own clipping cannot cut them
  off.
- Text cards are paragraphs, not headings. A decorative rail has no business
  injecting an `h2` into somebody else's outline.

## Reduced motion

Under `prefers-reduced-motion: reduce`:

- the drift does not run at all, and the pause button is not rendered, because
  there is nothing to pause;
- a release stops where it was let go — no inertia, since continuing to move
  after the gesture has ended is exactly what the preference asks not to see;
- arrow steps apply instantly instead of gliding;
- the hover zoom is dropped outright rather than made instant. A 0ms scale is a
  jump, and a jump is more motion than no zoom at all.

Dragging still tracks the pointer. That is direct manipulation: the movement is
the reader's own.

## Without JavaScript

The rail is served as an ordinary horizontal scroll container. The cards are a
row, the row scrolls, and a pointer or a keyboard can reach every one of them.

- No drift, no clones, no transform.
- **No grab cursor, no tab stop and no pause button**, all of which are added
  by the controller. A control that cannot work is worse than no control.
- No horizontal overflow on the page.

## Content length

- **A display line** — up to about four or five words. It is set at 17.5% of
  the card's width, so it is large by design; a long one wraps and eats the
  card.
- **A small line** — about 25 words on a 400px card, fewer on a phone.
- The card clips what does not fit. It cannot scroll: it lives inside a drag
  surface, and a nested scroller there fights the gesture. Long copy is
  therefore cut off rather than escaping the card or growing the rail — which
  is correct, but is a limit rather than a feature.

## Sizing

The card is `clamp(13.75rem, 74cqi, 25rem)` wide at an 8:11 ratio — 400×550 at
any rail wider than about 540px, shrinking to 289×397 at a 390px viewport and
237×326 at 320px. Type, padding and radius are all container query units
against the card, so they scale with it.

Retune from CSS without touching the file:

```css
.my-section .drifting-card-rail {
  --dcr-card-width: clamp(11rem, 60cqi, 20rem);
  --dcr-gap: 2rem;
  --dcr-radius: 0;
  --dcr-shadow: none;
  --dcr-zoom: 1;          /* no hover zoom */
}
```

## Copying it into another Astro project

Copy `DriftingCardRail.astro` into `src/components/` and copy whatever images
you pass in `cards`. Nothing else travels, and nothing else has to be
installed.

## Differences from the reference

Rebuilt from a Framer page, measured rather than copied. The card geometry,
the drift speed, the hover zoom and the way a release relaxes back into the
drift are all matched to measurements of the original. The behaviour around
them is not:

- **It is responsive.** The reference is 400px wide at every viewport it has,
  including a 320px phone, where the card is wider than the screen and can
  never be seen whole.
- **The motion can be stopped.** The reference has no pause control, does not
  pause on hover or focus, and has no reduced-motion path at all.
- **It has a keyboard.** The reference has no tab stop, no role and no key
  handling: zero focusable elements in the whole component.
- **It works without script**, as a scroll container. The reference renders a
  static row that cannot be moved at all.
- **It starts on its own.** The reference's drift did not begin until the first
  interaction on a cold load.
- The reference's display font is proprietary; this uses a plain serif stack.
- The reference offers video cards. This takes images only — a looping video
  behind a drag surface is a separate set of problems (autoplay policy, decode
  cost per clone, a pause control that then owns two kinds of motion), and
  shipping it half-done would be worse than leaving it out.

===== END src/components/DriftingCardRail.md =====

===== FILE (required): public/drifting-card-rail/dune.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 550" width="400" height="550" role="img">
  <defs>
    <linearGradient id="g" x1="0" y1="0" x2="0.3" y2="1">
      <stop offset="0" stop-color="#f3d9a4"/><stop offset="0.55" stop-color="#dba46a"/><stop offset="1" stop-color="#8f4f2c"/>
    </linearGradient>
    <radialGradient id="s" cx="0.7" cy="0.22" r="0.4">
      <stop offset="0" stop-color="#fff5dc" stop-opacity="0.95"/><stop offset="1" stop-color="#fff5dc" stop-opacity="0"/>
    </radialGradient>
  </defs>
  <rect width="400" height="550" fill="url(#g)"/>
  <circle cx="280" cy="121" r="140" fill="url(#s)"/>
  <path d="M0 392c74-38 122 14 196-16s130-52 204-12v186H0Z" fill="#7a3f22" opacity="0.55"/>
  <path d="M0 452c88-30 140 18 214-8s118-38 186-8v114H0Z" fill="#5d2d18" opacity="0.7"/>
</svg>

===== END public/drifting-card-rail/dune.svg =====

===== FILE (required): public/drifting-card-rail/tide.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 550" width="400" height="550" role="img">
  <defs>
    <linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="#0b2c46"/><stop offset="0.5" stop-color="#14527a"/><stop offset="1" stop-color="#061726"/>
    </linearGradient>
  </defs>
  <rect width="400" height="550" fill="url(#g)"/>
  <rect x="0" y="243" width="400" height="14" fill="#bfe4f5" opacity="0.85"/>
  <rect x="0" y="266" width="400" height="5" fill="#bfe4f5" opacity="0.4"/>
  <rect x="0" y="222" width="400" height="5" fill="#bfe4f5" opacity="0.4"/>
  <g fill="#9fd4ec" opacity="0.22">
    <rect x="42" y="318" width="316" height="3"/><rect x="72" y="352" width="256" height="3"/>
    <rect x="102" y="386" width="196" height="3"/><rect x="132" y="420" width="136" height="3"/>
  </g>
</svg>

===== END public/drifting-card-rail/tide.svg =====

===== FILE (required): public/drifting-card-rail/ember.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 550" width="400" height="550" role="img">
  <defs>
    <radialGradient id="g" cx="0.5" cy="0.58" r="0.62">
      <stop offset="0" stop-color="#ff7a2f"/><stop offset="0.42" stop-color="#b8310d"/><stop offset="1" stop-color="#180604"/>
    </radialGradient>
  </defs>
  <rect width="400" height="550" fill="#120403"/>
  <circle cx="200" cy="319" r="230" fill="url(#g)"/>
  <circle cx="200" cy="319" r="86" fill="#ffd9a8" opacity="0.92"/>
  <circle cx="200" cy="319" r="128" fill="none" stroke="#ffb066" stroke-width="2" opacity="0.5"/>
</svg>

===== END public/drifting-card-rail/ember.svg =====

===== FILE (required): public/drifting-card-rail/moss.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 550" width="400" height="550" role="img">
  <rect width="400" height="550" fill="#1d3b27"/>
  <g stroke="#9cc79a" stroke-width="6" stroke-linecap="round" opacity="0.55">
    <path d="M56 494V236"/><path d="M116 494V162"/><path d="M176 494V292"/>
    <path d="M236 494V118"/><path d="M296 494V264"/><path d="M356 494V198"/>
  </g>
  <rect x="0" y="494" width="400" height="56" fill="#0f2317"/>
  <circle cx="236" cy="96" r="26" fill="#e8f0cf" opacity="0.9"/>
</svg>

===== END public/drifting-card-rail/moss.svg =====

===== FILE (required): public/drifting-card-rail/chalk.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 550" width="400" height="550" role="img">
  <rect width="400" height="550" fill="#ece7dd"/>
  <circle cx="200" cy="230" r="112" fill="#24211d"/>
  <rect x="64" y="404" width="272" height="10" fill="#24211d" opacity="0.85"/>
  <rect x="64" y="436" width="180" height="10" fill="#24211d" opacity="0.5"/>
  <rect x="64" y="468" width="112" height="10" fill="#24211d" opacity="0.25"/>
</svg>

===== END public/drifting-card-rail/chalk.svg =====

===== FILE (required): public/drifting-card-rail/dusk.svg =====
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 550" width="400" height="550" role="img">
  <defs>
    <linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="#2b1b52"/><stop offset="0.55" stop-color="#6e3d7d"/><stop offset="1" stop-color="#d98a72"/>
    </linearGradient>
  </defs>
  <rect width="400" height="550" fill="url(#g)"/>
  <circle cx="118" cy="146" r="44" fill="#ffe9c9" opacity="0.92"/>
  <path d="M0 398l104-86 82 62 76-74 138 106v144H0Z" fill="#241540" opacity="0.72"/>
  <path d="M0 462l126-64 96 44 90-40 88 52v96H0Z" fill="#150c28" opacity="0.85"/>
</svg>

===== END public/drifting-card-rail/dusk.svg =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-17 at 1440, 1280, 1024, 834, 768, 640, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:cf6b0834b0dd11a0a4c1e0ae683827488b60ff58b98db199a4d4e7b13b70573e — MATCHES the files below.

--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.

--- 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
“Drifting card rail” from my Astro Component Library:
https://astro.baysixmedia.com/components/drifting-card-rail/

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
7 required + 1 supporting
Verified
2026-09-17
Client navigation
End-to-end passed against a real ClientRouter
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready component

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-17 at 1440, 1280, 1024, 834, 768, 640, 390, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
27 verification notes
  • Fingerprint re-recorded 2026-09-26 (Step 27.2, legibility) after a style-only change: a radial scrim of card colour behind the copy (::before on the content box, hidden under forced colours with the canvas and wash), the headline's second line set in italic at full ink instead of 80% ink, and the description mixed 45% toward the ink. Markup and the <script> block are byte-identical to the verified revision (sha256 of the script section compared before and after), so boot, teardown, client navigation, multi-instance and the shader are untouched. Re-checked with tools/quality-qa.ts at 320, 390, 768, 1280 and 1440: no overflow, no clipped text, no console errors, visible focus on the action, no running animation under reduced motion, forced colours rendering text with no overflow, long content at 390 and 768 without overflow. Not a new full verification run: WebKit and Gecko remain unobserved, as before.
  • Fingerprint re-recorded 2026-09-24 (Step 15, library-wide content safety) after a no-behaviour content change: the built-in instructional cards ("Pass a cards array") were removed; with no cards the component renders nothing. Style and script blocks are byte-identical to the verified revision, and every preview, integration-test and template page was built before and after and compared — this component's pages are identical apart from per-build random ids. The no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts (no props renders nothing; one prop; one nested field). No new full verification run was performed.
  • Every check below was run against the production build served by `astro preview`, not the dev server. The first lifecycle attempt was run against `astro dev` and discarded: Vite's HMR keeps a live interval, which the probe counts as a timer surviving teardown. Against the built output the same run reports zero live timers.
  • The reference was measured rather than eyeballed, and the numbers in the component are those measurements. Card 400x550 at 14px radius with a 24px gap (424px pitch) and a `0 26px 54px -18px rgb(0 0 0 / 0.55)` shadow; drift a constant 34px/s; hover zoom scale(1.02) over 700ms on cubic-bezier(0.19, 0.72, 0.22, 1). The release curve was sampled every 200ms across a flung rail: the amount by which the velocity exceeded the drift fell by a constant 0.628 per 200ms, which is 0.098 over a second, and that is the constant the controller uses. It is applied as a power of the frame delta, so frame rate does not change where the rail comes to rest.
  • The reference carries no rotation, no z translation and no per-card scale or opacity at any point, including mid-drag - every plate matrix stays 1,0,0,1 with z 0. The name notwithstanding there is no 3D in it, and none was invented here. Its depth cues are the drop shadow and the hover zoom.
  • The supplied recording was reduced to a slit-scan - one pixel row per frame, stacked - which shows dead-straight streaks across all 9.03 seconds. Constant velocity, no curvature, so the motion in the recording is the drift rather than a decaying fling. That agrees with the 34px/s measured on the live page.
  • Loop continuity was measured, not assumed. With the drift held, eight arrow steps move the rail by exactly one set (8 x 424 = 3392px) and the transform returns bit-identically to where it started, -567.105 both times on one run and -143.105 on another. Rendered output across that wrap differs by 2063 of 953,280 pixels at a maximum of 40/255, against a control of 298 pixels at 15/255 between two screenshots of the same unchanged position - antialiasing on card edges and the shadow gradient, not a seam. A crop across the wrap boundary shows the same content in the same place.
  • A real defect was found in the browser and fixed before this record was written. The scroller is focusable, so every mouse click - including the one at the end of every drag - left focus inside the rail; the focus pause then held the drift for the rest of the session, so the rail stopped the first time it was dragged and never started again. Measured at 0px/s 1.6 seconds after the pointer had left. The pause now tests :focus-visible rather than focus. Re-measured: -34px/s once the pointer leaves, and still 0px/s while a tabbed-to control holds focus.
  • Pointer drag driven with a real mouse: 1:1 tracking (-200.2px for a 200px drag), a fling that decays back into the drift, zero clicks produced by a committed drag, and an ordinary click still reaching the card. Touch driven with real Input.dispatchTouchEvent through CDP in a touch context at 390px: pointerType touch, -208.9px for a 208px swipe, inertia on release, zero clicks, and a mostly-vertical gesture left to the page - the rail moved only the 16.8px the drift owed it and data-dcr-dragging was never set. A tap does not pause the drift, which is the pointerType guard doing its job.
  • Autoplay pausing: hover 0px/s and -34px/s after leaving; keyboard focus 0px/s and -34.4px/s after blur; the pause control 0px/s pressed and -34.3px/s released, with aria-pressed tracking. The hidden-document pause was verified by overriding document.hidden and dispatching the real visibilitychange event - 0px/s hidden, -34px/s visible again - because Emulation.setPageVisibilityOverride is not available in this Chromium build. A genuinely backgrounded tab was therefore not exercised; the browser also stops animation frames there on its own.
  • Reduced motion, in a prefers-reduced-motion: reduce context: zero drift over two seconds, the pause control not rendered because there is nothing to pause, the media transition computed to 0s, an arrow step applying instantly at exactly one pitch (424px), and a released drag moving 0px further. Dragging still tracks the pointer, which is direct manipulation rather than motion the interface started.
  • Keyboard: Tab reaches the scroller and then the pause control, the scroller carries role=group, the label and aria-keyshortcuts, and the focus ring is drawn inside the rail at a -3px offset so its own clipping cannot cut it. Arrow keys step exactly one pitch in each direction and are symmetric - at a 1000px rail, where the card is 400 and the gap 17, a right step measured -417.00 and the left step back +417.00, returning to the starting offset. The rail holds still while focus is inside it: 0px/s once settled.
  • No JavaScript: the rail is served as an ordinary overflow-x: auto scroll container. Eight cards, zero clones, no transform, no grab cursor, no tab stop, no pause button, zero focusable elements, images loaded with their alt text, and no horizontal overflow. Nothing that cannot work is shown.
  • Two instances on one page are independent: A at -33.9px/s leftward and B at +95.2px/s rightward from the fixture's own secondInstanceProps, distinct labels, eight authored cards and eight clones each, and zero duplicate ids.
  • Clones are inert: aria-hidden, inert and data-dcr-clone, with zero focusable elements and zero ids inside them, so the list assistive technology hears is the eight cards that were authored.
  • Ratios and overflow at all eight widths: 400x550 from 640 to 1440, 289x397 at 390 and 237x326 at 320, ratio 0.727 at every one, scrollWidth equal to clientWidth at every one, and the pause control 44x44 and inside the rail at every one. Rail height 662 at 1440 and 1280, 652 at 1024, 638 from 834 down to 640, 485 at 390 and 414 at 320, with 44 to 56px of clearance below the card inside the clipping box at every width. 200 per cent zoom emulated as a halved 1440x800 viewport (720x400): no overflow and the control still reachable.
  • Hostile content: a 204-character unbroken word and a 220-word line were injected into a text card at 390px. The text stays inside the card, the card does not resize, and the page does not overflow. What does not fit is clipped, because the card cannot scroll inside a drag surface - documented as a content-length limit rather than called a pass.
  • Lifecycle: the ten-step run was scored by the repo's own evaluateLifecycleRun(), unmodified, which returned pass with no failures. At astro:before-swap all 12 signal-bearing registrations were aborted, zero were live, zero clones survived, no teardown attribute was left behind and zero timers were live. After navigating, the sentinel and the single unchanged navigation entry proved the transition was client-side, both instances re-initialised, and one input produced exactly one response. A second navigation left live registrations at 12, live controllers at 2 and timers at 0.
  • The probe in src/lib/lifecycle-probe.ts counts ready roots with a selector hard-coded to two other components' attributes, and a profile-less entry carries no driver contract. Only that selector was adapted in the injected copy, and the root, teardown and clone selectors were supplied through the fixture contract at run time. Nothing in the repo was edited and the pass/fail rules were used exactly as they are.
  • Zero console errors and zero off-origin requests across the preview route and the fixture route in a clean browser context: 18 requests, all same-origin - the two documents, one stylesheet, the component's own module and the six local SVGs.
  • A second defect was reported by a user against the shipped render and fixed before this record was written: the card shadow stopped dead at a hard horizontal line along the bottom edge of every card. Cause, isolated by measurement rather than guessed - the scroller's box was exactly the card band (top 50, bottom 600, height 550, identical to the card) and it carried `overflow: hidden`. The sideways clip is what hides the wrap, and `hidden` cannot clip one axis and leave the other alone: a `visible` opposite axis computes to `auto`. So the vertical clip came along with it, and a box-shadow paints outside the border box, so it was cut at a hard edge. Nothing was covering it and no stacking context was involved.
  • The first fix was wrong and is recorded because it is instructive: the rail's breathing room was `padding-block` on the root, outside the clipper, so it was moved inside the clipping box onto the scroller, sized against an arithmetic estimate of a 35px shadow reach. That only moved the line 56px down, because the estimate was half the truth. Measured on the rendered card with the clip released, sampling a column of pixels below the card edge: 163 of 255 at the card edge, 201 at 20px, 229 at 40px, 240 at 60px, 242 at 70px and 244 - the page - at 90px. Chromium's blur reaches about a full blur radius past the shadow box, not half of one, so a 54px blur is still visible 70px down. Reserving that on every rail is absurd.
  • The fix that holds removes the vertical clip instead of trying to out-size it: `overflow-x: clip` with `overflow-y: visible`, which `clip` permits and `hidden` does not, behind an `@supports (overflow: clip)` guard with the old `overflow: hidden` left as the fallback. Re-measured on the shipped build: the grey profile below the card now decays 163, 201, 229, 240, 242, 244 with a largest single-row step of 3 of 255, and that step is 15px below the card where the gradient is steepest - not at the rail's edge. The sideways clip is unaffected, `scrollWidth === clientWidth` at all eight widths, and the shadow now spills a few pixels past the rail's own box, which is ordinary shadow behaviour and imperceptible at four levels out of 255.
  • Every check in this record was re-executed against this revision after both fixes, not carried over: the eight-width sweep, the zoom emulation, the focus ring, drag and fling, click separation, hover, focus and control pausing, reduced motion, the no-script render, two instances, clone inertness, console and request counts, and the full ten-step lifecycle run, which returned pass again with the same figures. Fresh readings on the shipped build: drift -32.2px/s sampled over 800ms, 1:1 drag at -200.0px for a 200px drag, a fling at -1844.9px/s decaying back, zero clicks from a drag and one from a click, hover, focus and control pauses all settling to exactly 0px/s, -34.2px/s once the pointer leaves, and arrow steps of -417.00 and +417.00 at a 1000px rail where the pitch is 417. The drag surface is now the whole rail rather than the card band, so a drag started in the whitespace above the cards works too.
  • The fingerprint covers five files, not one: the component and all four portraits, because assets referenced by URL are part of the installation whether or not anyone lists them. That is worth knowing before swapping the artwork for photographs - it is the right thing to do with this component and it will expire this verdict, which is the mechanism working rather than failing.
  • Three defaults were changed on request after the first build and no longer match the reference: duration 1 to 2 and repeatDelay 0.5 to 1, which halves the pace, and maskRadius 500px to none, so the grid runs the full width rather than fading to a centred vignette. Re-measured after the change: the loop is 9s against the previous 4.5s, the mask computes as none with the grid reaching both edges of its box, the peak opacity still lands at 0.499 against a declared 0.5, and a square still produced 13 distinct transforms over four seconds. Passing the reference values restores the reference behaviour.
  • Chromium only. No WebKit or Gecko build was available in this session, and no claim is made about either.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/DriftingCardRail.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/drifting-card-rail/dune.svg, public/drifting-card-rail/tide.svg, public/drifting-card-rail/ember.svg, public/drifting-card-rail/moss.svg, public/drifting-card-rail/chalk.svg, public/drifting-card-rail/dusk.svg. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
  • packagesInstall alongside it: npm i astro
---
/**
 * DriftingCardRail — a full-bleed rail of tall cards that drifts sideways on
 * its own, can be grabbed and flung, and loops forever without a seam.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts and no network requests of its own: the only things it ever
 * loads are the images you pass in.
 *
 * The decisions that carry the weight, and why they are shaped this way:
 *
 *   - **One transform, not one per card.** The whole track is a single flex
 *     row and the controller moves it with one `translate3d`. The obvious
 *     alternative — absolutely positioning every card and writing a transform
 *     to each one every frame — costs a style recalculation per card per
 *     frame and puts the layout in JavaScript, where a reader cannot see it.
 *     Here the row is ordinary flex layout: the browser computes the
 *     geometry, the controller measures it, and with no script at all the
 *     cards are still a row you can scroll.
 *
 *   - **Velocity is the state, and there is only one of it.** Drift, flinging
 *     and coming to rest are not three behaviours; they are one number
 *     relaxing towards a target. The target is the drift speed while drift is
 *     allowed and zero while it is paused, and a release simply sets the
 *     number to the speed of the gesture. That is why a fling settles back
 *     into the drift instead of stopping dead and starting again, and why
 *     pausing eases off rather than snapping.
 *
 *   - **The loop is real cards, repeated.** The set you pass is measured, and
 *     enough whole copies are appended to cover the rail plus one set, so the
 *     wrap is a modulo on the offset rather than a jump between elements.
 *     Every copy is `aria-hidden`, `inert` and marked `data-dcr-clone`, so the
 *     list a screen reader hears is the list you wrote, once.
 *
 *   - **Motion you did not ask for can be stopped.** The rail carries a real
 *     play/pause button, pauses under the pointer and while focus is inside,
 *     stops when the document is hidden, and does not drift at all under
 *     `prefers-reduced-motion: reduce`. The reference this was rebuilt from
 *     had none of that: no control, no pause, no reduced-motion path, no
 *     keyboard at all.
 *
 *   - **Nothing that cannot work is shown.** Without script the rail is a
 *     horizontally scrollable list of cards — no grab cursor, no tab stop, no
 *     pause button, because a control that does nothing is worse than no
 *     control. The controller adds the affordances when it takes over.
 *
 * Several instances can share a page: every query is scoped to the instance
 * root and all state lives in the controller's closure.
 */
import type { ImageMetadata } from "astro";

export interface DriftingCardRailCard {
	/**
	 * Image for a media card. Its presence is what makes this a media card:
	 * omit it and the entry renders as a text card instead.
	 */
	src?: string | ImageMetadata;
	/** Alternative text. Omitted or empty marks the image decorative. */
	alt?: string;
	/** Text card: the line along the top edge. */
	top?: string;
	/** Text card: the line along the bottom edge. */
	bottom?: string;
	/**
	 * Which of the two lines is set at display size. The other is set small.
	 * Both arrangements are in the reference and they read very differently:
	 * a display line at the top is a title, a display line at the bottom is a
	 * signature.
	 */
	emphasis?: "top" | "bottom";
	/** Text card background. A value that is not a plain colour is ignored. */
	background?: string;
	/** Text card ink. The small line is the same ink, held back. */
	ink?: string;
}

export interface DriftingCardRailProps {
	/** The cards, in order. The set is repeated to fill the rail. */
	cards?: DriftingCardRailCard[];
	/**
	 * Drift speed in pixels per second. Clamped to 0–400; a bad value falls
	 * back to 34, which is what the reference runs at. Zero is a valid
	 * setting and means "only move when dragged".
	 */
	speed?: number;
	/** Which way the cards travel. */
	direction?: "left" | "right";
	/** Accessible name for the rail as a whole. */
	label?: string;
	/** Hold the drift while the pointer is over the rail or focus is inside. */
	pauseOnHover?: boolean;
	/** Render the play/pause control. Only ever shown when drift can run. */
	showPauseControl?: boolean;
	/** Accessible name for that control. It does not change when pressed. */
	pauseLabel?: string;
	/** Optional extra class for project-specific layout. */
	class?: string;
	/** Element id. */
	id?: string;
}

interface Props extends DriftingCardRailProps {}

const {
	cards,
	speed = 34,
	direction = "left",
	label = "Featured cards",
	pauseOnHover = true,
	showPauseControl = true,
	pauseLabel = "Pause the drifting cards",
	class: className,
	id,
}: Props = Astro.props;

/*
 * Numbers are normalised rather than trusted. `speed` reaches the controller
 * through a data attribute and is multiplied by a frame delta, so a NaN would
 * turn the offset into NaN on the first frame and the rail would vanish. The
 * upper bound is a legibility limit rather than a technical one: past a few
 * hundred pixels a second the cards are a blur.
 */
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
	const parsed = typeof value === "number" ? value : Number(value);
	if (!Number.isFinite(parsed)) return fallback;
	return Math.min(max, Math.max(min, parsed));
}

const safeSpeed = clampNumber(speed, 34, 0, 400);
const safeDirection = direction === "right" ? "right" : "left";

/** Control characters are stripped first: "java\tscript:" is a scheme too. */
function stripControls(value: string): string {
	return [...value]
		.filter((character) => {
			const code = character.codePointAt(0) ?? 0;
			return code > 31 && code !== 127;
		})
		.join("");
}

/**
 * Image sources are treated as untrusted: they usually arrive from a CMS or a
 * content collection and become an attribute on every render. Only the schemes
 * that can execute are refused — `data:` is allowed, because an inline SVG is
 * a legitimate image.
 */
function safeSrc(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const bare = stripControls(value.trim()).trim();
	if (bare === "") return undefined;
	if (/^(javascript|vbscript):/i.test(bare)) return undefined;
	return bare;
}

/**
 * Card colours are the one place a caller-supplied string reaches CSS.
 *
 * They are written into an inline `style` as custom properties, so the value
 * is checked rather than escaped: anything carrying a semicolon, a brace, a
 * `url(` or an at-rule could add declarations of its own to that element.
 * What survives is what a colour actually looks like — a hex value, a
 * functional notation, or a bare keyword — and nothing else. A refused value
 * is dropped, so the card renders in the default palette rather than
 * unstyled.
 */
const COLOUR = /^(#[0-9a-f]{3,8}|[a-z]+|(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix)\([^;{}()]*\))$/i;

function safeColour(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const bare = stripControls(value.trim()).trim();
	if (bare === "" || bare.length > 64) return undefined;
	return COLOUR.test(bare) ? bare : undefined;
}

/* Both string paths and imported Astro assets. An asset also carries its
   intrinsic size, which lets the browser reserve the right box. */
function mediaOf(card: DriftingCardRailCard) {
	const metadata = card.src && typeof card.src === "object" ? (card.src as ImageMetadata) : undefined;
	const src = safeSrc(metadata ? metadata.src : card.src);
	if (!src) return undefined;
	return {
		src,
		width: Number.isFinite(metadata?.width) ? metadata!.width : undefined,
		height: Number.isFinite(metadata?.height) ? metadata!.height : undefined,
	};
}

/* No stand-in cards: with none passed, or none that carry an image or a
   line of text, the rail renders nothing. A card with nothing to show is
   dropped rather than drawn as an empty tile. */
const hasText = (value: unknown) => typeof value === "string" && value.trim() !== "";
const list = (Array.isArray(cards) ? cards : []).filter(
	(card) => card && (mediaOf(card) || hasText(card.top) || hasText(card.bottom)),
);

/* Resolved once, in the frontmatter, so the template stays a template: every
   value below has already been through validation. */
const resolved = list.map((card) => {
	const media = mediaOf(card);
	const background = safeColour(card.background);
	const ink = safeColour(card.ink);
	const style = [
		background ? `--dcr-card-bg: ${background};` : "",
		ink ? `--dcr-card-ink: ${ink};` : "",
	]
		.join(" ")
		.trim();
	return {
		media,
		alt: typeof card.alt === "string" ? card.alt : "",
		top: typeof card.top === "string" ? card.top : "",
		bottom: typeof card.bottom === "string" ? card.bottom : "",
		emphasis: card.emphasis === "bottom" ? "bottom" : "top",
		style: style === "" ? undefined : style,
	};
});
---

{resolved.length > 0 && (
<div
	class:list={["drifting-card-rail", className]}
	id={id}
	data-drifting-card-rail
	data-dcr-speed={String(safeSpeed)}
	data-dcr-direction={safeDirection}
	data-dcr-pause-hover={pauseOnHover ? "1" : "0"}
	data-dcr-control={showPauseControl ? "1" : "0"}
	data-dcr-label={label}
>
	{/*
	    A plain div rather than a landmark. The cards are a list and say so;
	    wrapping them in a named region as well would add a second thing to
	    page past for no gain. The controller names the scroller instead, once
	    it can actually be operated.
	*/}
	<div class="dcr-viewport" data-dcr-viewport>
		<ul class="dcr-track" data-dcr-track>
			{resolved.map((card) => (
				<li class="dcr-card" data-dcr-emphasis={card.emphasis} style={card.style}>
					{card.media ? (
						<img
							class="dcr-media"
							src={card.media.src}
							alt={card.alt}
							width={card.media.width}
							height={card.media.height}
							draggable="false"
							decoding="async"
						/>
					) : (
						/*
						 * Paragraphs, not headings. These are display lines on a
						 * decorative rail, and a component that injected h2s into
						 * somebody else's page would corrupt their outline for the
						 * sake of looking like a title.
						 */
						<div class="dcr-copy">
							<p class="dcr-line dcr-line--top">{card.top}</p>
							<p class="dcr-line dcr-line--bottom">{card.bottom}</p>
						</div>
					)}
				</li>
			))}
		</ul>
	</div>

	{showPauseControl && (
		<button class="dcr-toggle" type="button" data-dcr-toggle hidden aria-pressed="false">
			<span class="dcr-toggle-label">{pauseLabel}</span>
			<svg class="dcr-glyph dcr-glyph--pause" aria-hidden="true" viewBox="0 0 24 24" width="18" height="18">
				<path d="M9 5v14M15 5v14" />
			</svg>
			<svg class="dcr-glyph dcr-glyph--play" aria-hidden="true" viewBox="0 0 24 24" width="18" height="18">
				<path d="M8 5.5v13l11-6.5z" />
			</svg>
		</button>
	)}
</div>
)}

<script>
	/**
	 * DriftingCardRail controller.
	 *
	 * One closure per instance. Every query is scoped to that instance's root,
	 * every listener carries an AbortController signal, and the whole thing is
	 * torn down before a client-side navigation swaps the document.
	 *
	 * The animation frame is not left running when there is nothing to do. A
	 * paused rail cancels its own loop and a resumed one starts a new frame,
	 * so an idle instance costs nothing and a torn-down one leaves no callback
	 * behind for the lifecycle probe to find.
	 */
	interface Instance {
		destroy(): void;
	}

	interface Tween {
		from: number;
		to: number;
		start: number;
		duration: number;
	}

	function initDriftingCardRail(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second set of listeners. */
		if (root.dataset.dcrReady === "") return undefined;

		const viewportQuery = root.querySelector<HTMLElement>("[data-dcr-viewport]");
		const trackQuery = root.querySelector<HTMLElement>("[data-dcr-track]");
		if (!viewportQuery || !trackQuery) return undefined;

		const viewport = viewportQuery;
		const track = trackQuery;
		const originals = Array.from(track.children) as HTMLElement[];
		if (originals.length === 0) return undefined;

		const toggle = root.querySelector<HTMLButtonElement>("[data-dcr-toggle]");

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

		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
		const speed = Number(root.dataset.dcrSpeed) || 0;
		const heading = root.dataset.dcrDirection === "right" ? 1 : -1;
		const pauseOnHover = root.dataset.dcrPauseHover !== "0";
		const label = root.dataset.dcrLabel || "Featured cards";

		/*
		 * How much of a velocity's excess over the drift survives one second.
		 *
		 * Measured off the reference rather than chosen: sampling a released
		 * fling every 200ms, the amount by which the velocity exceeded the
		 * drift fell by a constant 0.628 each time, which is 0.098 over a
		 * second. Applied as a power of the frame delta rather than per frame,
		 * so a 144Hz display and a 60Hz one come to rest together.
		 */
		const RELAX_PER_SECOND = 0.098;
		/* Past this a flick is a throw. The reference has no limit and a
		   trackpad flick can leave the cards unreadable for a second. */
		const MAX_FLING = 4000;
		/* Below this a press is a click rather than a drag. */
		const THRESHOLD = 8;

		/* ---- geometry ------------------------------------------------- */

		let span = 0; /* one full set of cards, including the trailing gap */
		let pitch = 0; /* one card plus one gap */

		function measure() {
			const width = originals[0].getBoundingClientRect().width;
			const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
			pitch = width + gap;
			span = pitch * originals.length;
		}

		function clearClones() {
			for (const clone of Array.from(track.querySelectorAll("[data-dcr-clone]"))) {
				clone.remove();
			}
		}

		/**
		 * Repeat the set until it covers the rail plus one whole set.
		 *
		 * That is what lets the wrap be a modulo: at any offset within one
		 * span there is always a full set of cards to the right of the fold,
		 * so the row can be snapped back by exactly one span without anything
		 * visibly moving.
		 *
		 * The copies are `aria-hidden` and `inert`, so the list stays the
		 * length the author wrote and nothing inside a copy can take focus.
		 */
		function fill() {
			clearClones();
			measure();
			if (span <= 0) return;
			const needed = Math.ceil(viewport.getBoundingClientRect().width / span) + 1;
			const fragment = document.createDocumentFragment();
			for (let set = 1; set < needed; set += 1) {
				for (const card of originals) {
					const copy = card.cloneNode(true) as HTMLElement;
					copy.dataset.dcrClone = "";
					copy.setAttribute("aria-hidden", "true");
					copy.setAttribute("inert", "");
					fragment.appendChild(copy);
				}
			}
			track.appendChild(fragment);
		}

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

		let offset = 0;
		let velocity = 0;
		let frameId: number | undefined;
		let lastFrame = 0;
		let tween: Tween | null = null;

		let hovering = false;
		let focused = false;
		let manuallyPaused = false;
		let dragging = false;

		const driftVelocity = () => heading * speed;

		function driftAllowed(): boolean {
			if (speed <= 0 || reduced.matches) return false;
			if (manuallyPaused || document.hidden) return false;
			if (pauseOnHover && (hovering || focused)) return false;
			return true;
		}

		function apply() {
			if (span <= 0) return;
			let x = offset % span;
			if (x > 0) x -= span;
			track.style.transform = `translate3d(${x}px, 0, 0)`;
		}

		/* Ease-out cubic. Only the keyboard step uses it; everything else is
		   the velocity model, which has an easing of its own by construction. */
		const ease = (t: number) => 1 - Math.pow(1 - t, 3);

		function frame(now: number) {
			frameId = window.requestAnimationFrame(frame);
			const delta = Math.min(0.05, (now - lastFrame) / 1000);
			lastFrame = now;
			if (dragging || span <= 0) return;

			if (tween) {
				const progress = Math.min(1, (now - tween.start) / tween.duration);
				offset = tween.from + (tween.to - tween.from) * ease(progress);
				if (progress >= 1) tween = null;
				apply();
				return;
			}

			const target = driftAllowed() ? driftVelocity() : 0;
			velocity = target + (velocity - target) * Math.pow(RELAX_PER_SECOND, delta);
			/* Half a pixel a second is not motion. Snapping the last of it away
			   is what lets a paused rail stop its own frame loop. */
			if (Math.abs(velocity - target) < 0.5) velocity = target;
			if (velocity === 0) {
				stopFrames();
				return;
			}
			offset += velocity * delta;
			offset %= span;
			apply();
		}

		function startFrames() {
			if (frameId !== undefined) return;
			lastFrame = performance.now();
			frameId = window.requestAnimationFrame(frame);
		}

		function stopFrames() {
			if (frameId === undefined) return;
			window.cancelAnimationFrame(frameId);
			frameId = undefined;
		}

		/** Whatever changed, work out whether the loop should be running. */
		function refresh() {
			syncToggle();
			if (driftAllowed() || velocity !== 0 || tween) startFrames();
		}

		/* ---- the pause control ---------------------------------------- */

		/* Shown only where it can do something. Under reduced motion, or with
		   the speed at zero, there is no drift to pause. */
		const controlWanted = root.dataset.dcrControl !== "0";

		function syncToggle() {
			if (!toggle) return;
			const usable = controlWanted && speed > 0 && !reduced.matches;
			toggle.hidden = !usable;
			toggle.setAttribute("aria-pressed", manuallyPaused ? "true" : "false");
		}

		toggle?.addEventListener(
			"click",
			() => {
				manuallyPaused = !manuallyPaused;
				refresh();
			},
			{ signal },
		);

		/* ---- pointer dragging ------------------------------------------ */

		let pointerId: number | null = null;
		let startX = 0;
		let startY = 0;
		let lastX = 0;
		let lastTime = 0;
		let moved = false;
		let gestureVelocity = 0;
		/* Armed by a committed drag so the click it produces is not also
		   followed. It disarms itself; see endDrag. */
		let suppressClick = false;
		let suppressTimer: number | undefined;

		viewport.addEventListener(
			"pointerdown",
			(event) => {
				if (event.button !== 0) return;
				pointerId = event.pointerId;
				startX = lastX = event.clientX;
				startY = event.clientY;
				lastTime = event.timeStamp;
				moved = false;
				gestureVelocity = 0;
				suppressClick = false;
				window.clearTimeout(suppressTimer);
			},
			{ signal },
		);

		/*
		 * The rest of the gesture is tracked on the window rather than on the
		 * rail, and without pointer capture.
		 *
		 * A drag that carries the pointer off the rail — which is most of
		 * them, on a rail that is only as tall as one card — otherwise stops
		 * producing events the moment the pointer leaves, and the `pointerup`
		 * that ends the drag never arrives. Capture was supposed to prevent
		 * that and does not hold for touch pointers in this library's
		 * experience, so the window is the reliable place to listen.
		 */
		window.addEventListener(
			"pointermove",
			(event) => {
				if (pointerId === null || event.pointerId !== pointerId) return;
				const dx = event.clientX - startX;
				const dy = event.clientY - startY;

				if (!moved) {
					if (Math.abs(dx) < THRESHOLD) return;
					/* A mostly-vertical gesture belongs to the page, not to us. */
					if (Math.abs(dy) > Math.abs(dx)) {
						pointerId = null;
						return;
					}
					moved = true;
					dragging = true;
					tween = null;
					root.dataset.dcrDragging = "";
					stopFrames();
				}

				const step = event.clientX - lastX;
				const elapsed = Math.max(1, event.timeStamp - lastTime) / 1000;
				lastX = event.clientX;
				lastTime = event.timeStamp;
				offset += step;
				/* Smoothed, so one stuttering frame at the end of a gesture
				   cannot decide how hard the rail was thrown. */
				gestureVelocity = gestureVelocity * 0.72 + (step / elapsed) * 0.28;
				apply();
			},
			{ signal },
		);

		function endDrag() {
			if (pointerId === null) return;
			pointerId = null;
			if (!moved) return;
			moved = false;
			dragging = false;
			delete root.dataset.dcrDragging;

			/*
			 * A mouse drag is followed by a click on whatever sat under the
			 * pointer, so that click has to be swallowed. A touch swipe is not
			 * followed by one at all — so the suppressor cannot wait for a
			 * click to clear it, or the next click to reach the rail is eaten
			 * instead. It expires on its own.
			 */
			suppressClick = true;
			window.clearTimeout(suppressTimer);
			suppressTimer = window.setTimeout(() => {
				suppressClick = false;
			}, 400);

			/*
			 * Under reduced motion a release stops where it was let go. The
			 * drift is suppressed there, and continuing to move after the
			 * gesture has ended is exactly the kind of motion that preference
			 * is asking not to see.
			 */
			velocity = reduced.matches
				? 0
				: Math.max(-MAX_FLING, Math.min(MAX_FLING, gestureVelocity));
			refresh();
		}

		window.addEventListener("pointerup", endDrag, { signal });
		window.addEventListener("pointercancel", endDrag, { signal });
		/* A touch the browser takes over for scrolling ends the drag too. */
		window.addEventListener("touchcancel", endDrag, { signal });

		viewport.addEventListener(
			"click",
			(event) => {
				if (!suppressClick) return;
				suppressClick = false;
				window.clearTimeout(suppressTimer);
				event.preventDefault();
				event.stopPropagation();
			},
			{ capture: true, signal },
		);

		viewport.addEventListener("dragstart", (event) => event.preventDefault(), { signal });

		/* ---- keyboard --------------------------------------------------- */

		/*
		 * The keyboard equivalent of a drag, and the reason the rail is worth
		 * making focusable at all: without it the only way to see the cards
		 * behind you is to wait for them to come round again, which is not an
		 * interface. One press moves the rail by exactly one card.
		 */
		viewport.addEventListener(
			"keydown",
			(event) => {
				if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
				if (event.altKey || event.ctrlKey || event.metaKey) return;
				event.preventDefault();
				const towards = event.key === "ArrowRight" ? -1 : 1;
				const destination = offset + towards * pitch;
				if (reduced.matches) {
					offset = destination;
					apply();
					return;
				}
				tween = { from: offset, to: destination, start: performance.now(), duration: 420 };
				startFrames();
			},
			{ signal },
		);

		/* ---- pausing ---------------------------------------------------- */

		/*
		 * Hover is a mouse affordance. `pointerenter` fires for a touch tap
		 * too, and a rail that paused for the rest of the session because
		 * somebody once tapped it would look broken.
		 */
		viewport.addEventListener(
			"pointerenter",
			(event) => {
				if (event.pointerType === "touch") return;
				hovering = true;
				refresh();
			},
			{ signal },
		);

		viewport.addEventListener(
			"pointerleave",
			(event) => {
				if (event.pointerType === "touch") return;
				hovering = false;
				refresh();
			},
			{ signal },
		);

		/**
		 * Whether someone is reading this rail with the keyboard.
		 *
		 * `:focus-visible` rather than plain focus, and the distinction is the
		 * whole point. The scroller is focusable, so a mouse click anywhere on
		 * the rail — including the click at the end of every drag — leaves
		 * focus sitting inside it. Treating that as "someone is working here"
		 * pauses the drift for the rest of the session: the rail stops the
		 * first time it is dragged and never starts again, which reads as a
		 * component that broke rather than one that paused.
		 */
		function keyboardFocusInside(target: Element | null): boolean {
			if (!target) return false;
			try {
				return target.matches(":focus-visible");
			} catch {
				/* No :focus-visible support: keep the safe answer, which is to
				   hold the motion rather than move under someone's hands. */
				return true;
			}
		}

		root.addEventListener("focusin", (event) => {
			focused = keyboardFocusInside(event.target as Element | null);
			refresh();
		}, { signal });

		root.addEventListener("focusout", (event) => {
			if (root.contains(event.relatedTarget as Node | null)) return;
			focused = false;
			refresh();
		}, { signal });

		document.addEventListener("visibilitychange", refresh, { signal });
		reduced.addEventListener("change", refresh, { signal });

		/* ---- resize ------------------------------------------------------ */

		/*
		 * The card width is a container query away from the rail's own width,
		 * so a resize changes the span, the pitch and how many copies are
		 * needed. Rebuilt on an animation frame rather than per observer
		 * callback, because a drag of a window edge fires a great many of
		 * those and each rebuild touches the DOM.
		 */
		let resizePending = false;
		const observer = new ResizeObserver(() => {
			if (resizePending) return;
			resizePending = true;
			window.requestAnimationFrame(() => {
				resizePending = false;
				fill();
				apply();
			});
		});
		observer.observe(viewport);

		/* ---- boot --------------------------------------------------------- */

		/*
		 * The scroller becomes a control only now that there is something
		 * behind it. Served without these, it is an ordinary overflow box that
		 * scrolls with no script at all — and nothing announces a keyboard
		 * shortcut that does not exist.
		 */
		viewport.scrollLeft = 0;
		viewport.setAttribute("tabindex", "0");
		viewport.setAttribute("role", "group");
		viewport.setAttribute("aria-label", label);
		viewport.setAttribute("aria-keyshortcuts", "ArrowLeft ArrowRight");

		fill();
		apply();
		refresh();

		function destroy() {
			/* Back to exactly the state the markup was served in, so a re-used
			   element cannot inherit a running rail from the previous route. */
			delete root.dataset.dcrReady;
			delete root.dataset.dcrDragging;
			stopFrames();
			window.clearTimeout(suppressTimer);
			observer.disconnect();
			clearClones();
			track.style.transform = "";
			viewport.removeAttribute("tabindex");
			viewport.removeAttribute("role");
			viewport.removeAttribute("aria-label");
			viewport.removeAttribute("aria-keyshortcuts");
			if (toggle) {
				toggle.hidden = true;
				toggle.setAttribute("aria-pressed", "false");
			}
			offset = 0;
			velocity = 0;
			tween = null;
			pointerId = null;
			dragging = false;
			manuallyPaused = false;
			controller.abort();
		}

		/*
		 * Astro replaces the document on a client-side navigation. Detach every
		 * listener, return the rail to its served state, and clear the ready
		 * flag so a re-used element can boot again.
		 */
		document.addEventListener("astro:before-swap", destroy, { once: true, signal });

		return { destroy };
	}

	const boot = () =>
		document
			.querySelectorAll<HTMLElement>("[data-drifting-card-rail]")
			.forEach(initDriftingCardRail);

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.drifting-card-rail {
		/* ---- overridable tokens -------------------------------------------
		   Set any of these on the element, from a class, an id or a wrapper
		   rule, to retune the component without touching this file. */

		/*
		 * The reference is 400px wide at every viewport it has, including a
		 * 320px phone, where the card is wider than the screen and can never
		 * be seen whole. This keeps the 400px ceiling and lets the card fall
		 * back to a share of the rail below it.
		 */
		--dcr-card-width: clamp(13.75rem, 74cqi, 25rem);
		/* 400 x 550 in the reference, which is exactly 8:11. */
		--dcr-ratio: 8 / 11;
		--dcr-gap: clamp(0.75rem, 1.7cqi, 1.5rem);
		--dcr-radius: clamp(0.5rem, calc(var(--dcr-card-width) * 0.035), 0.875rem);
		/*
		 * Breathing room above and below the cards, and the reason the floor is
		 * 2.75rem rather than something smaller: the pause control is 44px, and
		 * below that the rail would put it on top of a card on a phone.
		 *
		 * It is NOT what keeps the shadow off the edge. Measured on a rendered
		 * card, `0 26px 54px -18px` is still darker than the page 70px below it
		 * — twice what the arithmetic suggests, because Chromium's blur reaches
		 * about a full blur radius past the shadow box rather than half of one.
		 * Reserving that much empty space on every rail would be absurd, so the
		 * vertical clip is removed instead; see `.dcr-viewport`.
		 */
		--dcr-inset: clamp(2.75rem, 5cqi, 3.5rem);
		--dcr-surface: #e8e3da;
		--dcr-shadow: 0 26px 54px -18px rgb(0 0 0 / 0.55);
		/* The zoom the media takes under the pointer, and the curve it takes
		   to get there. Both measured off the reference. */
		--dcr-zoom: 1.02;
		--dcr-zoom-duration: 700ms;
		--dcr-zoom-ease: cubic-bezier(0.19, 0.72, 0.22, 1);
		--dcr-card-bg: #24211d;
		--dcr-card-ink: #f2f0ec;
		--dcr-ring: #1b1a18;

		box-sizing: border-box;
		/* Queried by the card width above, so the rail responds to the column
		   it is dropped into rather than to the viewport. */
		container-type: inline-size;
		position: relative;
		display: block;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.drifting-card-rail :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	/*
	 * Served as an ordinary scroll container, which is the whole no-script
	 * story: the cards are a row, the row scrolls, and a pointer or a keyboard
	 * can reach every one of them with nothing running. The controller takes
	 * the scrolling away only once it can move the row itself.
	 */
	.dcr-viewport {
		/* On the scroller, not on the rail around it. Padding out here would
		   look the same at rest and still leave the shadow cut off, because
		   the clip happens at this element's box. */
		padding-block: var(--dcr-inset);
		overflow-x: auto;
		overflow-y: hidden;
		/* Vertical page scrolling is never ours to take. */
		touch-action: pan-y;
		-webkit-overflow-scrolling: touch;
	}

	.drifting-card-rail[data-dcr-ready] .dcr-viewport {
		overflow: hidden;
		cursor: grab;
		user-select: none;
		-webkit-user-select: none;
	}

	/*
	 * The rail has to clip sideways — that is what hides the wrap — and it must
	 * not clip downwards, or it cuts its own drop shadow off at a hard
	 * horizontal line under every card.
	 *
	 * `overflow: hidden` cannot do one axis and not the other: a `visible`
	 * opposite axis computes to `auto`, which would put a scrollbar on a
	 * surface the controller is already moving by hand. `clip` has no such
	 * rule, and that is the only reason it is here. The rule above stays as the
	 * fallback, where the shadow is clipped at the inset rather than at the
	 * card's edge — a soft step of about four levels out of 255, against the
	 * hard line the card edge produced.
	 */
	@supports (overflow: clip) {
		.drifting-card-rail[data-dcr-ready] .dcr-viewport {
			overflow-x: clip;
			overflow-y: visible;
		}
	}

	.drifting-card-rail[data-dcr-dragging] .dcr-viewport {
		cursor: grabbing;
	}

	.dcr-viewport:focus-visible {
		/* Drawn inside, so the rail's own clipping cannot cut the ring off. */
		outline: 3px solid var(--dcr-ring);
		outline-offset: -3px;
	}

	.dcr-track {
		display: flex;
		flex-wrap: nowrap;
		gap: var(--dcr-gap);
		width: max-content;
		margin: 0;
		/* The first card sits a gap in from the edge, so a card at rest never
		   looks glued to the side of the page. */
		padding: 0 var(--dcr-gap);
		list-style: none;
		will-change: transform;
	}

	.dcr-card {
		position: relative;
		flex: none;
		width: var(--dcr-card-width);
		aspect-ratio: var(--dcr-ratio);
		border-radius: var(--dcr-radius);
		overflow: hidden;
		background: var(--dcr-surface);
		box-shadow: var(--dcr-shadow);
		/* So the copy inside scales with the card rather than with the rail. */
		container-type: inline-size;
	}

	.dcr-media {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: center;
		transition: transform var(--dcr-zoom-duration) var(--dcr-zoom-ease);
	}

	.dcr-card:hover .dcr-media {
		transform: scale(var(--dcr-zoom));
	}

	/*
	 * While the rail is being dragged the zoom is off, transition and all. A
	 * card sliding out from under the pointer would otherwise hand the zoom to
	 * the next card along, and the rail would ripple as it moved.
	 */
	.drifting-card-rail[data-dcr-dragging] .dcr-media {
		transition: none;
		transform: none;
	}

	/* ---- text cards ----------------------------------------------------- */

	.dcr-copy {
		display: flex;
		flex-direction: column;
		justify-content: space-between;
		gap: 3.5cqi;
		width: 100%;
		height: 100%;
		padding: 6.5cqi;
		background: var(--dcr-card-bg);
	}

	.dcr-line {
		margin: 0;
		color: var(--dcr-card-ink);
		text-wrap: pretty;
		overflow-wrap: anywhere;
	}

	/*
	 * One line is display size and the other is not, and which is which is the
	 * card's own decision. Both arrangements are in the reference: a display
	 * line at the top reads as a title, the same line at the bottom reads as a
	 * signature under a caption.
	 */
	.dcr-card[data-dcr-emphasis="top"] .dcr-line--top,
	.dcr-card[data-dcr-emphasis="bottom"] .dcr-line--bottom {
		font: 400 17.5cqi / 1 Georgia, "Times New Roman", serif;
		letter-spacing: -0.015em;
	}

	.dcr-card[data-dcr-emphasis="top"] .dcr-line--bottom,
	.dcr-card[data-dcr-emphasis="bottom"] .dcr-line--top {
		font: 400 5.5cqi / 1.1 system-ui, sans-serif;
		letter-spacing: -0.02em;
		/* The quieter of the two, in the same ink rather than a second colour:
		   one colour per card is one thing to get right. */
		opacity: 0.74;
	}

	/* An empty line still holds its end of the card, so a one-line text card
	   keeps its copy where the design put it rather than centring it. */
	.dcr-line:empty::after {
		content: "";
		display: block;
	}

	/* ---- the pause control ---------------------------------------------- */

	.dcr-toggle {
		position: absolute;
		right: clamp(0.75rem, 2cqi, 1.5rem);
		bottom: clamp(0.75rem, 2cqi, 1.5rem);
		display: inline-flex;
		align-items: center;
		justify-content: center;
		width: 44px;
		height: 44px;
		margin: 0;
		padding: 0;
		border: 1px solid rgb(0 0 0 / 0.14);
		border-radius: 999px;
		background: rgb(255 255 255 / 0.86);
		color: #1b1a18;
		/* Lifts it off whatever is behind. The rail's vertical inset clears the
		   cards at desktop sizes but not on a phone, where the inset is smaller
		   than a 44px target — so the control has to read as a control over a
		   photograph as well as over the page. */
		box-shadow: 0 2px 10px rgb(0 0 0 / 0.18);
		cursor: pointer;
		-webkit-tap-highlight-color: transparent;
	}

	/* Beats the display rule above, so a control the controller has not
	   revealed stays gone rather than merely transparent. */
	.dcr-toggle[hidden] {
		display: none;
	}

	.dcr-toggle:focus-visible {
		outline: 3px solid var(--dcr-ring);
		outline-offset: 2px;
	}

	/* The name is on the button and reaches assistive technology; the glyph is
	   what the eye gets. */
	.dcr-toggle-label {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	.dcr-glyph {
		flex: none;
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.dcr-glyph--play {
		fill: currentColor;
		stroke: none;
	}

	/* Pressed means "the drift is paused", so the control offers play. */
	.dcr-toggle[aria-pressed="false"] .dcr-glyph--play,
	.dcr-toggle[aria-pressed="true"] .dcr-glyph--pause {
		display: none;
	}

	/* ---- reduced motion --------------------------------------------------
	   The drift and the fling are suppressed in the controller, which is the
	   only place they could be: they are a number, not a transition. What is
	   left here is the hover zoom, which is a transition and is dropped
	   outright rather than made instant — a 0ms scale is a jump, and a jump is
	   more motion than no zoom at all. */
	@media (prefers-reduced-motion: reduce) {
		.dcr-media {
			transition: none;
		}

		.dcr-card:hover .dcr-media {
			transform: none;
		}

		.dcr-track {
			will-change: auto;
		}
	}
</style>