Component library
Back to catalog

Scroll zoom video

A media band whose rounded video or still starts at half size and grows to fill the band as it scrolls into view. CSS scroll-driven, no scroll listener; full size under reduced motion or without support.

videomediascroll-drivenzoomscripted

Open preview in a new tab

Use this component

Three ways to get Scroll zoom video into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Scroll zoom video from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/scroll-zoom-video/

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

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

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

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Scroll zoom video"
Library page: https://astro.baysixmedia.com/components/scroll-zoom-video/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/ScrollZoomVideo.astro =====
---
/**
 * Scroll zoom video
 *
 * A band as tall as its media — 1200×700 at desktop, 335×195 on a phone —
 * holding one rounded video (or still) that starts at half size and grows to
 * fill the band as the band scrolls through the viewport.
 *
 * The growth is a CSS scroll-driven animation (`animation-timeline`), inside
 * `@supports`, so no scroll listener ever runs. A browser without it, and
 * anyone who asks for reduced motion, sees the media at full size.
 *
 * The one script only decides whether a supplied video may autoplay. It is
 * served paused with native controls; the script swaps those for muted
 * autoplay plus a pause button, and only when reduced motion is off.
 */

/** A still, or a video's poster frame. No `src` renders a labelled placeholder. */
export interface ZoomPoster {
	src?: string;
	/** Alt text for the still, and the video's accessible name. "" = decorative. */
	alt?: string;
	/** What the clip should show: the brief on the placeholder. */
	hint?: string;
}

interface Props {
	/**
	 * An mp4/webm to loop. Omit it and the band shows `poster` as a still, or
	 * a labelled placeholder when the poster has only a `hint`. With neither,
	 * the component renders nothing.
	 */
	videoSrc?: string;
	/** Still image, and the video's poster frame. */
	poster?: ZoomPoster;
	/** Turn the scroll-linked growth off and show the media at full size. */
	zoom?: boolean;
	/**
	 * `""` when the page already loads Bebas Neue and Poppins. Only the
	 * placeholder sets text; the prop exists so every section has one API.
	 */
	fontStylesheet?: string;
	id?: string;
}

const {
	videoSrc,
	poster = {},
	zoom = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
	id,
} = Astro.props;

const label = poster.alt ?? "";
/* The measured box is 1200×700 (12:7); spec for a 1.6× master. */
const spec = "12:7 · 1920 × 1120 px, MP4/H.264, muted";
/*
 * Nothing to show, nothing rendered: the band appears for a video, a still, or
 * a poster that explicitly asks for the placeholder (by giving it a hint).
 * Omitted entirely, a production page gets no empty "Video" box.
 */
const hasMedia = Boolean(videoSrc || poster?.src || poster?.hint);
---

{hasMedia && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="szv" id={id} data-szv data-szv-zoom={zoom ? "" : undefined}>
	<div class="szv-inner">
		<div class="szv-scale">
			<div class="szv-frame">
				{videoSrc ? (
					<>
						{/*
						 * Served with controls and no `autoplay`: the server cannot
						 * know the reader's motion preference, so the safe state is
						 * the one that never moves on its own. The script upgrades it.
						 */}
						<video
							class="szv-media"
							src={videoSrc}
							poster={poster.src}
							muted
							loop
							playsinline
							controls
							preload="metadata"
							aria-label={label || undefined}
							data-szv-video
						/>
						<button class="szv-toggle" type="button" hidden data-szv-toggle aria-label="Pause video">
							<svg class="szv-icon szv-icon--pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
								<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
							</svg>
							<svg class="szv-icon szv-icon--play" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
								<path d="M8 5l11 7-11 7z" fill="currentColor" />
							</svg>
						</button>
					</>
				) : (
					poster.src ? (
						<img class="szv-media" src={poster.src} alt={label} loading="lazy" decoding="async" />
					) : (
						<div class="szv-media szv-ph" role="img" aria-label={`Video placeholder: ${poster.hint ?? "looping clip"}`}>
							<span class="szv-ph-text">
								<span class="szv-ph-label">Video</span>
								{poster.hint && <span class="szv-ph-hint">{poster.hint}</span>}
								<span class="szv-ph-spec">{spec}</span>
							</span>
						</div>
					)
				)}
			</div>
		</div>
	</div>
</div>
</>
)}

<script>
	/*
	 * Autoplay gate for ScrollZoomVideo.
	 *
	 * Nothing here touches the zoom — that is pure CSS. This only upgrades a
	 * served-paused video to muted autoplay when the reader has not asked for
	 * reduced motion, and gives it a pause button (WCAG 2.2.2) in place of the
	 * native controls it loses. If the preference flips while the page is open,
	 * it goes back to paused-with-controls.
	 */
	function initScrollZoomVideo(root: HTMLElement): void {
		if (root.dataset.szvReady === "") return;
		const video = root.querySelector<HTMLVideoElement>("[data-szv-video]");
		const toggle = root.querySelector<HTMLButtonElement>("[data-szv-toggle]");
		if (!video || !toggle) return;

		root.dataset.szvReady = "";
		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		const sync = () => {
			const playing = !video.paused;
			toggle.setAttribute("aria-label", playing ? "Pause video" : "Play video");
			toggle.dataset.state = playing ? "playing" : "paused";
		};

		const apply = () => {
			if (reduced.matches) {
				video.pause();
				video.controls = true;
				toggle.hidden = true;
				return;
			}
			video.controls = false;
			toggle.hidden = false;
			video.muted = true;
			/* A rejected play() (data saver, a browser policy) leaves the
			   poster up and the button offering Play — nothing to recover. */
			video.play().catch(() => sync());
		};

		video.addEventListener("play", sync, { signal });
		video.addEventListener("pause", sync, { signal });
		toggle.addEventListener(
			"click",
			() => {
				if (video.paused) video.play().catch(() => sync());
				else video.pause();
			},
			{ signal },
		);
		reduced.addEventListener("change", apply, { signal });

		apply();
		sync();

		document.addEventListener(
			"astro:before-swap",
			() => {
				video.pause();
				video.controls = true;
				toggle.hidden = true;
				delete root.dataset.szvReady;
				controller.abort();
			},
			{ once: true, signal },
		);
	}

	const boot = () => document.querySelectorAll<HTMLElement>("[data-szv]").forEach(initScrollZoomVideo);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.szv {
		box-sizing: border-box;
		overflow: clip;
		font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
	}

	/* The band is exactly as tall as the full-size media: 700 at a 1200 container, 195 at 335. */
	.szv-inner {
		box-sizing: border-box;
		max-width: calc(1200px + 60px);
		margin-inline: auto;
		padding-inline: 20px;
	}

	@media (min-width: 810px) {
		.szv-inner {
			padding-inline: 30px;
		}
	}

	.szv-scale {
		transform-origin: 50% 50%;
	}

	.szv-frame {
		position: relative;
		aspect-ratio: 1200 / 700;
		overflow: hidden;
		/* On the scaled element, so it reads 5px at half size — as measured. */
		border-radius: 10px;
		background: var(--lx-surface, #f6f6f6);
	}

	.szv-media {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* ---- placeholder (no video, no poster src) --------------------------- */

	.szv-ph {
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		border-radius: inherit;
		--szv-ph-ink: rgba(10, 37, 64, 0.62);
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: var(--szv-ph-ink);
		border: 1px dashed color-mix(in srgb, var(--szv-ph-ink) 35%, transparent);
	}

	.szv-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		max-width: 28ch;
	}

	.szv-ph-label {
		font-size: 11px;
		font-weight: 500;
		line-height: 1.2;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.szv-ph-hint {
		font-size: 13px;
		font-weight: 500;
		line-height: 1.4;
	}

	.szv-ph-spec {
		font-size: 12px;
		font-weight: 400;
		line-height: 1.3;
	}

	.szv-toggle {
		position: absolute;
		right: 12px;
		bottom: 12px;
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 50%;
		background: rgba(10, 37, 64, 0.72);
		color: #ffffff;
		cursor: pointer;
	}

	.szv-toggle[hidden] {
		display: none;
	}

	.szv-toggle:hover {
		background: var(--lx-navy, #0a2540);
	}

	.szv-toggle:focus-visible {
		outline: 3px solid var(--lx-accent, #ff5f00);
		outline-offset: 2px;
	}

	.szv-icon {
		width: 20px;
		height: 20px;
	}

	.szv-toggle:not([data-state="paused"]) .szv-icon--play,
	.szv-toggle[data-state="paused"] .szv-icon--pause {
		display: none;
	}

	/*
	 * Scroll-linked growth, 0.5 → 1.
	 *
	 * The named view timeline sits on the band, so progress is measured from
	 * the band's layout box rather than the scaled one. It finishes when the
	 * band's centre reaches the viewport's centre. Everything is inside
	 * `@supports` and a motion query, so the resting state everywhere else
	 * is full size.
	 */
	@supports (animation-timeline: view()) {
		.szv[data-szv-zoom] {
			view-timeline-name: --szv-band;
			view-timeline-axis: block;
		}

		@media (prefers-reduced-motion: no-preference) {
			.szv[data-szv-zoom] .szv-scale {
				animation: szv-grow linear both;
				animation-timeline: --szv-band;
				animation-range: entry 0% cover 50%;
			}
		}
	}

	@keyframes szv-grow {
		from {
			transform: scale(0.5);
		}
		to {
			transform: scale(1);
		}
	}

	@media (forced-colors: active) {
		.szv-frame {
			border: 1px solid CanvasText;
		}
	}
</style>

===== END src/components/ScrollZoomVideo.astro =====

===== FILE (supporting): src/components/ScrollZoomVideo.md =====
# Scroll zoom video

A band holding one rounded video or still that starts at half size and grows
to fill the band as it scrolls through the viewport. The band is always as
tall as the full-size media: 1200 × 700 at desktop, 335 × 195 on a phone.

Catalog page: `/components/scroll-zoom-video`

## Purpose

A visual pause between sections: a reel of trucks, a warehouse walkthrough,
a drone pass over the yard. It has no copy. Pair it with a heading in the
section above.

## Files required

**`ScrollZoomVideo.astro`**: the whole component.

- No imports, no packages.
- The zoom is **pure CSS** (`animation-timeline`). The small script only
  gates video autoplay; see below.
- `fontStylesheet` exists for API parity with the rest of the set. Only the
  placeholder sets text, so pass `""` if you want to skip the request.

`ScrollZoomVideo.md`: this file.

No media files. With no video, no poster `src` and no poster `hint` (the
default) the component renders nothing; a poster with only a `hint` shows a
labelled placeholder; see **Images**.

## Basic usage

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

<!-- a still -->
<ScrollZoomVideo poster={{ src: "/img/fleet.jpg", alt: "Our fleet at the Sparks yard" }} />

<!-- a looping video -->
<ScrollZoomVideo videoSrc="/video/fleet.mp4" poster={{ src: "/img/fleet.jpg", alt: "Fleet leaving the yard" }} />
```

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `videoSrc` | `string?` | none | A video to loop. Omit it and the band shows the poster. |
| `poster` | `{ src?, alt?, hint? }?` | `{}` (no `src`, no hint) | The still and the video's poster frame. `alt` is the still's `alt` or the video's `aria-label` (`""` = decorative). A `hint` with no `src` and no video = placeholder; nothing at all = no render. |
| `zoom` | `boolean?` | `true` | `false` shows the media at full size with no scroll effect. |
| `fontStylesheet` | `string?` | Google Fonts URL | `""` turns the `<link>` off. |
| `id` | `string?` | none | Root id. |

## Images

One slot. With a `poster.hint` but neither `videoSrc` nor `poster.src`, a striped placeholder
fills the rounded box, labelled `VIDEO` with the hint (if you pass one) and
the spec, and `role="img"` with `aria-label="Video placeholder: …"` ("looping
clip" when there is no hint). It scales with the scroll exactly as the media
would.

| Slot | Default hint | Spec | Measured box |
| :--- | :--- | :--- | :--- |
| Video / poster | none | 12:7 · 1920 × 1120 px, MP4/H.264, muted | 1200 × 700 (335 × 195 phone) |

The catalog demo's hint (a truck on a highway at dusk) is a registry demo
prop, not a component default.

A poster still for the same slot should be the same 12:7 at 1920 × 1120.

## Behaviour

### The zoom

A named view timeline (`--szv-band`) sits on the band, and the media wrapper
animates `transform: scale(0.5)` → `scale(1)` along it with
`animation-range: entry 0% cover 50%`. So it starts at half size as the band
enters the bottom of the viewport and reaches full size when the band's
centre crosses the viewport's centre. Progress is read from the band's
layout box, never from the scaled element. That keeps it stable.

The whole rule is inside `@supports (animation-timeline: view())` and
`prefers-reduced-motion: no-preference`. Everywhere else the media simply
sits at full size. **There is no scroll listener**, with or without support.

The 10px corner radius is on the scaled element, so it reads 5px at half
size, as in the reference.

### The video

The server cannot know the reader's motion preference, so a video is served
in the safe state: **paused, with native controls, no `autoplay`**, showing
the poster. The script then:

- if reduced motion is **off**: hides the native controls, plays it muted
  and looping, and shows a 44px pause/play button in the corner (WCAG 2.2.2:
  anything that moves on its own for more than five seconds needs a pause);
- if reduced motion is **on**: leaves it paused with controls;
- if the preference changes while the page is open, it re-applies.

If `play()` is refused (data saver, browser policy), the poster stays up and
the button offers Play.

## Lifecycle

The script follows the library pattern: it boots on load and on
`astro:page-load`, each instance scoped to its own root, and every listener is
registered through an `AbortController` that aborts on `astro:before-swap`.
It never scrolls the window or moves focus. With no `videoSrc` the script
finds nothing and does nothing.

## Accessibility

- A still has real `alt`; a video gets `aria-label` from the same field.
- The placeholder is `role="img"` with a "Video placeholder: …" label.
- The pause button is a native `<button>` with a label that tracks state
  ("Pause video" / "Play video") and a visible orange focus ring.
- Without JS, or with reduced motion, the native controls are there.
- No audio: the video is always muted.

## Responsive

Width is the container (max 1200, 30px inline padding at ≥ 810px viewport,
20px below); height follows from `aspect-ratio: 1200 / 700`. At a 375px page
width that is 335 × 195, the measured phone band.

## Reduced motion

Full size, no scroll-linked transform, no autoplay.

## Without JavaScript

The zoom still works (CSS). A video shows its poster with native controls.

## Approximations

- **Animation range.** The dump caught one frame at `scale(0.5)`. Where the
  growth starts and ends in the scroll isn't measurable from a static dump;
  `entry 0% → cover 50%` is a judgement call that reads like the live page.
- **Easing**: linear against scroll. The reference may use a spring (Framer
  often does) — not verifiable without a recording.
- **No video by default.** The reference's mp4 can't be copied, so the
  default is a labelled placeholder. The video branch (autoplay gate, pause button)
  has not been exercised in a browser with a real file. Check it manually
  when a video is supplied.
- The pause button is an addition: the reference autoplays with no control.

===== END src/components/ScrollZoomVideo.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

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

Read it first
“Scroll zoom video” from my Astro Component Library:
https://astro.baysixmedia.com/components/scroll-zoom-video/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Ships script
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Event-tested only — not certified against a real router
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/ScrollZoomVideo.astro

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

---
/**
 * Scroll zoom video
 *
 * A band as tall as its media — 1200×700 at desktop, 335×195 on a phone —
 * holding one rounded video (or still) that starts at half size and grows to
 * fill the band as the band scrolls through the viewport.
 *
 * The growth is a CSS scroll-driven animation (`animation-timeline`), inside
 * `@supports`, so no scroll listener ever runs. A browser without it, and
 * anyone who asks for reduced motion, sees the media at full size.
 *
 * The one script only decides whether a supplied video may autoplay. It is
 * served paused with native controls; the script swaps those for muted
 * autoplay plus a pause button, and only when reduced motion is off.
 */

/** A still, or a video's poster frame. No `src` renders a labelled placeholder. */
export interface ZoomPoster {
	src?: string;
	/** Alt text for the still, and the video's accessible name. "" = decorative. */
	alt?: string;
	/** What the clip should show: the brief on the placeholder. */
	hint?: string;
}

interface Props {
	/**
	 * An mp4/webm to loop. Omit it and the band shows `poster` as a still, or
	 * a labelled placeholder when the poster has only a `hint`. With neither,
	 * the component renders nothing.
	 */
	videoSrc?: string;
	/** Still image, and the video's poster frame. */
	poster?: ZoomPoster;
	/** Turn the scroll-linked growth off and show the media at full size. */
	zoom?: boolean;
	/**
	 * `""` when the page already loads Bebas Neue and Poppins. Only the
	 * placeholder sets text; the prop exists so every section has one API.
	 */
	fontStylesheet?: string;
	id?: string;
}

const {
	videoSrc,
	poster = {},
	zoom = true,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
	id,
} = Astro.props;

const label = poster.alt ?? "";
/* The measured box is 1200×700 (12:7); spec for a 1.6× master. */
const spec = "12:7 · 1920 × 1120 px, MP4/H.264, muted";
/*
 * Nothing to show, nothing rendered: the band appears for a video, a still, or
 * a poster that explicitly asks for the placeholder (by giving it a hint).
 * Omitted entirely, a production page gets no empty "Video" box.
 */
const hasMedia = Boolean(videoSrc || poster?.src || poster?.hint);
---

{hasMedia && (
<>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="szv" id={id} data-szv data-szv-zoom={zoom ? "" : undefined}>
	<div class="szv-inner">
		<div class="szv-scale">
			<div class="szv-frame">
				{videoSrc ? (
					<>
						{/*
						 * Served with controls and no `autoplay`: the server cannot
						 * know the reader's motion preference, so the safe state is
						 * the one that never moves on its own. The script upgrades it.
						 */}
						<video
							class="szv-media"
							src={videoSrc}
							poster={poster.src}
							muted
							loop
							playsinline
							controls
							preload="metadata"
							aria-label={label || undefined}
							data-szv-video
						/>
						<button class="szv-toggle" type="button" hidden data-szv-toggle aria-label="Pause video">
							<svg class="szv-icon szv-icon--pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
								<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
							</svg>
							<svg class="szv-icon szv-icon--play" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
								<path d="M8 5l11 7-11 7z" fill="currentColor" />
							</svg>
						</button>
					</>
				) : (
					poster.src ? (
						<img class="szv-media" src={poster.src} alt={label} loading="lazy" decoding="async" />
					) : (
						<div class="szv-media szv-ph" role="img" aria-label={`Video placeholder: ${poster.hint ?? "looping clip"}`}>
							<span class="szv-ph-text">
								<span class="szv-ph-label">Video</span>
								{poster.hint && <span class="szv-ph-hint">{poster.hint}</span>}
								<span class="szv-ph-spec">{spec}</span>
							</span>
						</div>
					)
				)}
			</div>
		</div>
	</div>
</div>
</>
)}

<script>
	/*
	 * Autoplay gate for ScrollZoomVideo.
	 *
	 * Nothing here touches the zoom — that is pure CSS. This only upgrades a
	 * served-paused video to muted autoplay when the reader has not asked for
	 * reduced motion, and gives it a pause button (WCAG 2.2.2) in place of the
	 * native controls it loses. If the preference flips while the page is open,
	 * it goes back to paused-with-controls.
	 */
	function initScrollZoomVideo(root: HTMLElement): void {
		if (root.dataset.szvReady === "") return;
		const video = root.querySelector<HTMLVideoElement>("[data-szv-video]");
		const toggle = root.querySelector<HTMLButtonElement>("[data-szv-toggle]");
		if (!video || !toggle) return;

		root.dataset.szvReady = "";
		const controller = new AbortController();
		const { signal } = controller;
		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		const sync = () => {
			const playing = !video.paused;
			toggle.setAttribute("aria-label", playing ? "Pause video" : "Play video");
			toggle.dataset.state = playing ? "playing" : "paused";
		};

		const apply = () => {
			if (reduced.matches) {
				video.pause();
				video.controls = true;
				toggle.hidden = true;
				return;
			}
			video.controls = false;
			toggle.hidden = false;
			video.muted = true;
			/* A rejected play() (data saver, a browser policy) leaves the
			   poster up and the button offering Play — nothing to recover. */
			video.play().catch(() => sync());
		};

		video.addEventListener("play", sync, { signal });
		video.addEventListener("pause", sync, { signal });
		toggle.addEventListener(
			"click",
			() => {
				if (video.paused) video.play().catch(() => sync());
				else video.pause();
			},
			{ signal },
		);
		reduced.addEventListener("change", apply, { signal });

		apply();
		sync();

		document.addEventListener(
			"astro:before-swap",
			() => {
				video.pause();
				video.controls = true;
				toggle.hidden = true;
				delete root.dataset.szvReady;
				controller.abort();
			},
			{ once: true, signal },
		);
	}

	const boot = () => document.querySelectorAll<HTMLElement>("[data-szv]").forEach(initScrollZoomVideo);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}
	document.addEventListener("astro:page-load", boot);
</script>

<style>
	.szv {
		box-sizing: border-box;
		overflow: clip;
		font-family: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
	}

	/* The band is exactly as tall as the full-size media: 700 at a 1200 container, 195 at 335. */
	.szv-inner {
		box-sizing: border-box;
		max-width: calc(1200px + 60px);
		margin-inline: auto;
		padding-inline: 20px;
	}

	@media (min-width: 810px) {
		.szv-inner {
			padding-inline: 30px;
		}
	}

	.szv-scale {
		transform-origin: 50% 50%;
	}

	.szv-frame {
		position: relative;
		aspect-ratio: 1200 / 700;
		overflow: hidden;
		/* On the scaled element, so it reads 5px at half size — as measured. */
		border-radius: 10px;
		background: var(--lx-surface, #f6f6f6);
	}

	.szv-media {
		display: block;
		width: 100%;
		height: 100%;
		object-fit: cover;
	}

	/* ---- placeholder (no video, no poster src) --------------------------- */

	.szv-ph {
		display: grid;
		place-items: center;
		padding: 16px;
		text-align: center;
		border-radius: inherit;
		--szv-ph-ink: rgba(10, 37, 64, 0.62);
		background: repeating-linear-gradient(135deg, #e6e9ee 0 12px, #edf0f3 12px 24px);
		color: var(--szv-ph-ink);
		border: 1px dashed color-mix(in srgb, var(--szv-ph-ink) 35%, transparent);
	}

	.szv-ph-text {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 6px;
		max-width: 28ch;
	}

	.szv-ph-label {
		font-size: 11px;
		font-weight: 500;
		line-height: 1.2;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	.szv-ph-hint {
		font-size: 13px;
		font-weight: 500;
		line-height: 1.4;
	}

	.szv-ph-spec {
		font-size: 12px;
		font-weight: 400;
		line-height: 1.3;
	}

	.szv-toggle {
		position: absolute;
		right: 12px;
		bottom: 12px;
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 50%;
		background: rgba(10, 37, 64, 0.72);
		color: #ffffff;
		cursor: pointer;
	}

	.szv-toggle[hidden] {
		display: none;
	}

	.szv-toggle:hover {
		background: var(--lx-navy, #0a2540);
	}

	.szv-toggle:focus-visible {
		outline: 3px solid var(--lx-accent, #ff5f00);
		outline-offset: 2px;
	}

	.szv-icon {
		width: 20px;
		height: 20px;
	}

	.szv-toggle:not([data-state="paused"]) .szv-icon--play,
	.szv-toggle[data-state="paused"] .szv-icon--pause {
		display: none;
	}

	/*
	 * Scroll-linked growth, 0.5 → 1.
	 *
	 * The named view timeline sits on the band, so progress is measured from
	 * the band's layout box rather than the scaled one. It finishes when the
	 * band's centre reaches the viewport's centre. Everything is inside
	 * `@supports` and a motion query, so the resting state everywhere else
	 * is full size.
	 */
	@supports (animation-timeline: view()) {
		.szv[data-szv-zoom] {
			view-timeline-name: --szv-band;
			view-timeline-axis: block;
		}

		@media (prefers-reduced-motion: no-preference) {
			.szv[data-szv-zoom] .szv-scale {
				animation: szv-grow linear both;
				animation-timeline: --szv-band;
				animation-range: entry 0% cover 50%;
			}
		}
	}

	@keyframes szv-grow {
		from {
			transform: scale(0.5);
		}
		to {
			transform: scale(1);
		}
	}

	@media (forced-colors: active) {
		.szv-frame {
			border: 1px solid CanvasText;
		}
	}
</style>