Component library
Back to catalog

Starfield

A perspective starfield flying toward the viewer on one canvas, as a page backdrop or behind a hero's own content.

backgroundcanvasanimationherodark

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/starfield/

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: "Starfield"
Library page: https://astro.baysixmedia.com/components/starfield/
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/Starfield.astro =====
---
/**
 * Starfield
 *
 * A perspective starfield flying toward the viewer, drawn on one canvas
 * behind whatever you put in the slot. Stars stream outward from a vanishing
 * point, accelerating and growing as they approach, and are recycled to the
 * back when they pass the camera.
 *
 * It is a backdrop, not a picture: the canvas is `aria-hidden`, takes no
 * pointer events, and sits under the content rather than beside it.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image. Every colour and measurement is a `--sf-*` custom property.
 */

interface Props {
	/**
	 * Stars per 100,000 square pixels. Density rather than a count, so a wide
	 * screen does not end up sparser than a narrow one. The reference runs at
	 * about 12.8.
	 */
	density?: number;
	/** Hard ceiling on the star count, so a very large screen stays cheap. */
	maxStars?: number;
	/**
	 * How fast the field comes at you, as a multiple of the reference's pace.
	 * `1` crosses the full depth in about six and a half seconds.
	 */
	speed?: number;
	/**
	 * Where the stars stream from before the pointer has moved, as fractions
	 * of the box. The reference starts on the horizontal centre and a quarter
	 * of the way down, which is what tilts the field slightly downward.
	 *
	 * A starting point, not a home: once the pointer has aimed the field it
	 * stays aimed.
	 */
	focusX?: number;
	focusY?: number;
	/**
	 * Let the vanishing point follow the pointer.
	 *
	 * The field streams out of wherever the cursor is, and holds that angle
	 * once the pointer stops or leaves — `focusX`/`focusY` is where it
	 * starts, not somewhere it returns to. Mouse and pen only; a touch drag
	 * is a scroll, not a look.
	 */
	pointerTracking?: boolean;
	/**
	 * How hard the camera banks toward the pointer. `0.5` is a half turn.
	 */
	pointerStrength?: number;
	/**
	 * A second population, in stars per 100,000 square pixels, that ignores
	 * the camera.
	 *
	 * These fly straight at the viewer at full speed whatever the camera is
	 * doing — they neither bank with it nor give up forward speed to a turn.
	 * They are added to `density` rather than taken out of it, so raising
	 * this makes the field busier.
	 *
	 * A few of them anchor the picture: with every star steering, a hard
	 * turn leaves nothing at all coming toward the viewer. `0` is the
	 * reference's own behaviour.
	 */
	anchoredDensity?: number;
	/** Radius of the nearest stars, in CSS pixels. The farthest are ~0.35. */
	maxRadius?: number;
	/** The stars. Any CSS colour; alpha is applied on top by depth. */
	starColor?: string;
	/** The ground behind them. `"transparent"` to lay this over something. */
	background?: string;
	/**
	 * How it sits in the page.
	 *
	 * `"section"` is a block in the flow that its own slot content sits on
	 * top of — a hero with a field behind it. `"page"` pins it to the
	 * viewport behind everything, which is the whole-page backdrop; the slot
	 * is ignored there, because there is nothing for it to be inside.
	 */
	mode?: "section" | "page";
	/**
	 * Height floor in `"section"` mode when the slot gives it none.
	 *
	 * Full screen by default, because that is what a field like this is
	 * for. `svh` rather than `vh` so a phone's collapsing address bar does
	 * not leave a strip of page showing under it.
	 */
	minHeight?: string;
	/**
	 * Cap on the backing-store scale. 2 is plenty; the reference does not
	 * scale at all and is visibly soft on any retina display.
	 */
	maxPixelRatio?: number;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	density = 12.8,
	maxStars = 420,
	speed = 1,
	focusX = 0.5,
	focusY = 0.25,
	pointerTracking = true,
	pointerStrength = 1,
	anchoredDensity = 0,
	maxRadius = 3.2,
	starColor = "#ffffff",
	background = "#000000",
	mode = "section",
	minHeight = "100svh",
	maxPixelRatio = 2,
	class: className,
	id,
} = Astro.props;

/* Clamped rather than trusted: each of these is multiplied into a per-frame
   loop, and a NaN or a negative would either stall the field or spin it. */
const num = (value: unknown, fallback: number, low: number, high: number) => {
	const n = Number(value);
	return Number.isFinite(n) ? Math.min(high, Math.max(low, n)) : fallback;
};

const safe = {
	density: num(density, 12.8, 0, 80),
	maxStars: Math.round(num(maxStars, 420, 1, 4000)),
	speed: num(speed, 1, 0, 8),
	focusX: num(focusX, 0.5, -1, 2),
	focusY: num(focusY, 0.25, -1, 2),
	pointerStrength: num(pointerStrength, 1, 0, 1),
	anchoredDensity: num(anchoredDensity, 0, 0, 80),
	maxRadius: num(maxRadius, 3.2, 0.5, 12),
	maxPixelRatio: num(maxPixelRatio, 2, 1, 4),
};

const style = [
	`--sf-star:${starColor}`,
	`--sf-bg:${background}`,
	`--sf-min-height:${minHeight}`,
].join(";");
---

<div
	class:list={["starfield", `starfield--${mode === "page" ? "page" : "section"}`, className]}
	id={id}
	style={style}
	data-starfield
	aria-hidden={mode === "page" ? "true" : undefined}
	data-sf-density={String(safe.density)}
	data-sf-max-stars={String(safe.maxStars)}
	data-sf-speed={String(safe.speed)}
	data-sf-focus-x={String(safe.focusX)}
	data-sf-focus-y={String(safe.focusY)}
	data-sf-max-radius={String(safe.maxRadius)}
	data-sf-pointer={pointerTracking ? "1" : "0"}
	data-sf-pointer-strength={String(safe.pointerStrength)}
	data-sf-anchored={String(safe.anchoredDensity)}
	data-sf-max-dpr={String(safe.maxPixelRatio)}
>
	{/*
	  Decoration, and marked as such twice over: out of the accessibility tree
	  and out of the way of the pointer. Nothing here is content.
	*/}
	<canvas class="sf-canvas" data-sf-canvas aria-hidden="true"></canvas>
	{mode !== "page" && <div class="sf-content"><slot /></div>}
</div>

<script>
	/*
	 * The field.
	 *
	 * Each star is a point in front of the camera: `x` and `y` in world units
	 * either side of the axis, `z` its distance, 1 at the back and ~0.08 at
	 * the eye. Projection is the one division that makes it look like depth —
	 * screen offset is `x / z`, so a star doubles its distance from the
	 * vanishing point every time its depth halves.
	 *
	 * Everything the reference shows falls out of that single rule rather
	 * than being animated separately: stars stream radially, they accelerate
	 * as they near the camera, and the nearer ones are both larger and faster
	 * than the far ones. Measured on the reference, the outer quarter of the
	 * field moves 1.35x as fast as the inner quarter, and the largest stars
	 * 1.23x as fast as the smallest.
	 */
	interface Star {
		x: number;
		y: number;
		z: number;
		/**
		 * The star's own brightness, fixed for its life.
		 *
		 * Not derived from depth, and that is a measurement rather than a
		 * preference: tracking stars that barely move on the reference gives
		 * a mean brightness change of 0.77 out of 255, so a star does not
		 * brighten as it approaches. Depth drives where it is and how fast;
		 * how bright it is belongs to the star.
		 */
		mag: number;
		/**
		 * The star's own sideways drift, fixed for its life.
		 *
		 * Without it the field is perfectly radial — every star on a line
		 * straight out from the vanishing point. Measured, the reference is
		 * not: its motion aligns with its focus at 0.66, where a pure
		 * projection gives 0.98. A touch of proper motion per star is what
		 * accounts for the difference, and it does a second job — it lifts
		 * the slow stars near the focus, which is what stops the field
		 * reading as a warp jump at the edges.
		 */
		dx: number;
		dy: number;
	}

	interface Instance {
		destroy(): void;
	}

	/*
	 * Depth at the eye and at the back of the field. A star that crosses
	 * Z_NEAR has passed the camera and goes to the back with a new position.
	 */
	const Z_NEAR = 0.3;
	const Z_FAR = 1;
	/* Depth per second at the back of the field, tuned to the reference's
	   measured mean of roughly 78 screen pixels per second at 1440 wide. */
	const Z_RATE = 0.42;
	/*
	 * How the depth rate falls off as a star approaches.
	 *
	 * A constant rate is the textbook fly-through. Screen speed goes as
	 * `r / z` and `r` itself goes as `1 / z`, so the field accelerates hard
	 * at the edges. Scaling the rate by `z^1.5` leaves screen speed going as
	 * `1 / sqrt(z)`, which is the same radial streaming at a more even pace.
	 *
	 * It does not fully close the gap to the reference, which measures a
	 * 1.35 spread between its inner and outer quarters against 3.4 here, and
	 * the honest reading is that some of that gap is the measurement rather
	 * than the design — see the verification notes. Narrowing the depth range
	 * was the other obvious lever and it made things worse: stars recycled
	 * sooner, so more of the field sat out at the fast edge.
	 */
	const Z_FALLOFF = 1.5;
	/* Stars are seeded across a box wider than the viewport so they arrive
	   from beyond the edges instead of appearing at them. */
	const MARGIN = 1.35;
	/* Spread of each star's own drift, in world units per second. */
	const DRIFT = 0.11;
	/*
	 * How quickly the camera settles into a new heading, as a time constant
	 * in seconds.
	 *
	 * Measured off the reference: the apparent vanishing point crosses
	 * roughly half the height of the frame in a third of a second, and it
	 * lags rather than snapping. Part of that measured lag is the ten-frame
	 * window used to read it, so this sits at the responsive end of what the
	 * recording supports.
	 */
	const STEER_TAU = 0.18;
	/*
	 * The camera's maximum turn, as the tangent of the angle off dead ahead
	 * with the pointer at an edge.
	 *
	 * This is a TURN, not a nudge, and that distinction is the whole of it.
	 * The camera has one velocity vector of fixed length; the pointer
	 * rotates it. Point at the edge and most of that speed becomes sideways
	 * travel, so the field stops coming at you and sweeps across instead.
	 *
	 * The reference does exactly this, and the evidence is that its
	 * expansion and its sideways slide are ANTI-CORRELATED: 4.89 px/frame of
	 * slide against 1.32 of expansion at one moment, 0.39 against 3.78 at
	 * another, with the total roughly constant. Adding a sideways push on
	 * top of an unchanged forward speed cannot produce that — the forward
	 * speed has to give way to the turn, which is what normalising the
	 * velocity vector below does.
	 */
	const STEER_THROW = 2.8;
	/*
	 * Tuned on the expansion the field keeps at full turn: the reference
	 * falls to 0.90 px/frame of expansion at its most sideways moment and
	 * this lands on 0.89. Its slide reads lower here only because slide
	 * scales with the screen - 3.66 at 1280 wide is 5.3 at the recording's
	 * 1864, against 5.8 measured.
	 */

	function initStarfield(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second loop. */
		if (root.dataset.sfReady === "") return undefined;

		const canvas = root.querySelector<HTMLCanvasElement>("[data-sf-canvas]");
		if (!canvas) return undefined;
		const ctx = canvas.getContext("2d");
		if (!ctx) return undefined;

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

		const read = (name: string, fallback: number) => {
			const value = Number(root.dataset[name]);
			return Number.isFinite(value) ? value : fallback;
		};
		const density = read("sfDensity", 12.8);
		const maxStars = read("sfMaxStars", 420);
		const speed = read("sfSpeed", 1);
		const focusX = read("sfFocusX", 0.5);
		const focusY = read("sfFocusY", 0.25);
		const maxRadius = read("sfMaxRadius", 3.2);
		const pointerStrength = read("sfPointerStrength", 1);
		const anchoredDensity = read("sfAnchored", 0);
		const tracking = root.dataset.sfPointer !== "0";
		const maxDpr = read("sfMaxDpr", 2);

		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		let stars: Star[] = [];
		/*
		 * The second population, kept in its own array rather than flagged
		 * inside the first. Membership IS the behaviour, so there is no
		 * per-star branch to get wrong and no flag to re-roll when a star
		 * recycles.
		 */
		let anchors: Star[] = [];
		let width = 0;
		let height = 0;
		let scale = 1;
		let projection = 1;
		let frame: number | undefined;
		let last = 0;
		let visible = true;
		/*
		 * How hard the camera is steering, and where it is being asked to.
		 * Both start at zero, so the field flies straight until the pointer
		 * says otherwise.
		 */
		let steerX = 0;
		let steerY = 0;
		let aimX = 0;
		let aimY = 0;

		/**
		 * Brightness, skewed toward the dim end.
		 *
		 * A uniform draw gives a field of headlights. The reference's stars
		 * average 98 out of 255 with the full range represented, which is
		 * what this exponent reproduces.
		 */
		const magnitude = () => 0.12 + 0.88 * Math.pow(Math.random(), 1.8);

		/**
		 * Place a star at a given depth, spread evenly across the view *at
		 * that depth*.
		 *
		 * Working back from a screen position rather than picking world
		 * coordinates directly is what keeps the field even. Pick `x` and `y`
		 * in world units instead and the far stars bunch around the vanishing
		 * point while the near ones are nearly all off-screen.
		 */
		function place(star: Star, z: number) {
			star.z = z;
			star.x = ((Math.random() - 0.5) * width * MARGIN * z) / projection;
			star.y = ((Math.random() - 0.5) * height * MARGIN * z) / projection;
			star.dx = (Math.random() - 0.5) * DRIFT;
			star.dy = (Math.random() - 0.5) * DRIFT;
		}

		function spawn(z: number): Star {
			const star: Star = { x: 0, y: 0, z, mag: magnitude(), dx: 0, dy: 0 };
			place(star, z);
			return star;
		}

		/**
		 * Size the backing store to the box.
		 *
		 * The reference leaves the canvas at CSS pixels, so on any display
		 * with a ratio above 1 every star is resampled and the field goes
		 * soft. This matches the device, capped — past 2 the extra fill costs
		 * more than it shows.
		 */
		function resize() {
			const box = canvas!.getBoundingClientRect();
			if (box.width < 1 || box.height < 1) return;
			const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);
			width = box.width;
			height = box.height;
			scale = dpr;
			canvas!.width = Math.round(width * dpr);
			canvas!.height = Math.round(height * dpr);
			/* Tied to the smaller side so the field does not stretch when the
			   box is wide and short. Needed before any star is placed. */
			projection = Math.min(width, height) * 0.5;

			/*
			 * Density, not a fixed count, so the field reads the same on a
			 * phone and on a wide monitor instead of thinning out.
			 */
			const fill = (list: Star[], perArea: number) => {
				const target = Math.min(
					maxStars,
					Math.max(0, Math.round((width * height * perArea) / 100000)),
				);
				if (list.length > target) list.length = target;
				while (list.length < target) {
					/* Seeded across the whole depth, not all at the back, so
					   the field is already full on the first frame. */
					list.push(spawn(Z_NEAR + Math.random() * (Z_FAR - Z_NEAR)));
				}
			};
			fill(stars, density);
			fill(anchors, anchoredDensity);
			draw();
		}

		function draw() {
			if (!ctx || width < 1) return;
			ctx.setTransform(scale, 0, 0, scale, 0, 0);
			ctx.clearRect(0, 0, width, height);

			const fx = width * focusX;
			const fy = height * focusY;
			const colour = getComputedStyle(root).getPropertyValue("--sf-star").trim() || "#fff";
			ctx.fillStyle = colour;

			/*
			 * Both populations, a pass each rather than a merged array.
			 * They look identical and only move differently, and this runs
			 * every frame for as long as the page is open — no reason to
			 * allocate a joined array sixty times a second to save a loop.
			 */
			const render = (paint: Star[]) => {
			for (const star of paint) {
				const sx = fx + (star.x / star.z) * projection;
				const sy = fy + (star.y / star.z) * projection;

				/*
				 * A star that has left the box is sent to the back now rather
				 * than left in the array. Skipping the draw and keeping it
				 * was the obvious version and it quietly thinned the field:
				 * the count stayed at its target while two thirds of it sat
				 * outside the frame, so 103 stars showed up as 31.
				 */
				if (sx < -12 || sx > width + 12 || sy < -12 || sy > height + 12) {
					place(star, Z_FAR);
					star.mag = magnitude();
					continue;
				}

				/* Size leans on depth, brightness does not — see `mag`. */
				const near = (Z_FAR - star.z) / (Z_FAR - Z_NEAR);
				const radius = Math.max(
					0.4,
					maxRadius * (0.35 + 0.65 * star.mag) * (0.55 + 0.45 * near),
				);

				ctx.globalAlpha = star.mag;
				ctx.beginPath();
				ctx.arc(sx, sy, radius, 0, Math.PI * 2);
				ctx.fill();
			}
			};

			render(stars);
			render(anchors);
			ctx.globalAlpha = 1;
		}

		function advance(dt: number) {
			const step = Z_RATE * speed * dt;
		/*
		 * Steering is applied to the WORLD, not to where the field is drawn
		 * from, and that distinction is the whole point of it.
		 *
		 * Moving the projection origin instead translates every star by the
		 * same number of pixels, so each one carries on along the heading it
		 * already had — the field slides sideways and nothing changes
		 * direction. Shifting the world shifts a star on screen by
		 * `delta / z`, so a near star swings several times as far as a
		 * distant one and every trajectory bends. That is parallax, and it
		 * is what makes the field read as a camera turning rather than a
		 * picture being dragged.
		 */
			/*
			 * One velocity vector, of fixed length, pointing where the
			 * camera is aimed. Normalising is what trades forward speed for
			 * sideways speed as the turn steepens — at full throw most of
			 * the travel is lateral and the field barely expands at all.
			 */
			const tilt = Math.hypot(steerX, steerY);
			const norm = 1 / Math.sqrt(1 + tilt * tilt);
			const forward = step * norm;

			/**
			 * Move one population.
			 *
			 * `lateral` is what separates the two: the steered set travels
			 * sideways by the tangent of the turn and pays for it out of its
			 * forward speed, while the anchored set gets the full step and
			 * no sideways travel at all.
			 */
			const move = (list: Star[], depthStep: number, lateral: number) => {
				for (const star of list) {
					star.z -= depthStep * Math.pow(star.z, Z_FALLOFF);
					star.x += star.dx * dt + steerX * lateral;
					star.y += star.dy * dt + steerY * lateral;
					if (star.z > Z_NEAR) continue;
					/* Past the camera: back of the field with a new position
					   and a new brightness. Reused in place, so the array
					   length and the allocation stay flat for the life of the
					   page. */
					place(star, Z_FAR);
					star.mag = magnitude();
				}
			};

			move(stars, forward, forward);
			/* Full speed, straight ahead, whatever the camera is doing. */
			move(anchors, step, 0);
		}

		/**
		 * Ease the camera toward the heading it has been given.
		 *
		 * Frame-rate independent: the exponential is in seconds, so the
		 * field settles at the same pace on a 60Hz panel and a 144Hz one
		 * rather than arriving nearly twice as fast on the latter.
		 */
		function easeSteer(dt: number) {
			const k = 1 - Math.exp(-dt / STEER_TAU);
			steerX += (aimX - steerX) * k;
			steerY += (aimY - steerY) * k;
		}

		function tick(now: number) {
			frame = window.requestAnimationFrame(tick);
			/* Clamped: a tab restored after a minute must not teleport the
			   field forward by a minute's worth of depth. */
			const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016);
			last = now;
			easeSteer(dt);
			advance(dt);
			draw();
		}

		function stop() {
			if (frame !== undefined) window.cancelAnimationFrame(frame);
			frame = undefined;
			last = 0;
		}

		function run() {
			stop();
			/*
			 * Frozen rather than hidden under a motion preference. The field
			 * is decoration; a still starfield still reads as one, where an
			 * empty box would just be a hole in the design.
			 */
			if (reduced.matches || !visible) {
				draw();
				return;
			}
			frame = window.requestAnimationFrame(tick);
		}

		/*
		 * requestAnimationFrame already parks itself when the tab is hidden.
		 * What it does not know is whether the field is still on screen, so a
		 * page that scrolls past it would keep painting for nothing.
		 */
		const observer = new IntersectionObserver(
			(entries) => {
				visible = entries.some((entry) => entry.isIntersecting);
				run();
			},
			{ rootMargin: "120px" },
		);
		observer.observe(root);

		const sizer = new ResizeObserver(() => resize());
		sizer.observe(root);

		/*
		 * The pointer moves the vanishing point.
		 *
		 * Listened for on the window rather than on the root, for two
		 * reasons: in page mode the root is `pointer-events: none` and would
		 * never see a move, and in section mode we want to know when the
		 * pointer has left as well as where it is. The root's own box is
		 * what decides whether it counts.
		 */
		if (tracking) {
			window.addEventListener(
				"pointermove",
				(event) => {
					/* A touch drag is a scroll, not a look. Pen and mouse only. */
					if (event.pointerType === "touch") return;
					if (reduced.matches) return;
					const box = root.getBoundingClientRect();
					if (box.width < 1 || box.height < 1) return;
					const inside =
						event.clientX >= box.left &&
						event.clientX <= box.right &&
						event.clientY >= box.top &&
						event.clientY <= box.bottom;
					/*
					 * Outside the box, the field holds the angle it was last
					 * given rather than springing back to rest. Snapping home
					 * the moment the pointer leaves reads as the field
					 * rejecting you; holding reads as it having been aimed.
					 */
					if (!inside) return;
					const px = (event.clientX - box.left) / box.width;
					const py = (event.clientY - box.top) / box.height;
					/*
					 * Offset from the middle, not from `focusX`/`focusY` —
					 * this is a heading rather than a destination. Centre is
					 * straight ahead; an edge is a full turn.
					 *
					 * Negated, because steering is the camera's motion and
					 * the field is what you see of it: turn toward the
					 * cursor and the world sweeps the other way, which is
					 * what puts the point everything streams out of under
					 * the pointer. Taking the sign the obvious way round
					 * sent the stars toward the cursor instead, with the
					 * vanishing point fleeing to the far corner.
					 */
					aimX = -(px - 0.5) * 2 * STEER_THROW * pointerStrength;
					aimY = -(py - 0.5) * 2 * STEER_THROW * pointerStrength;
				},
				{ signal, passive: true },
			);

		}

		reduced.addEventListener("change", run, { signal });
		window.addEventListener("pageshow", run, { signal });

		resize();
		run();

		function destroy() {
			stop();
			observer.disconnect();
			sizer.disconnect();
			if (ctx) {
				ctx.setTransform(1, 0, 0, 1, 0, 0);
				ctx.clearRect(0, 0, canvas!.width, canvas!.height);
			}
			/* Back to the served state: an unsized canvas with nothing on it. */
			canvas!.removeAttribute("width");
			canvas!.removeAttribute("height");
			stars = [];
			anchors = [];
			steerX = aimX = 0;
			steerY = aimY = 0;
			delete root.dataset.sfReady;
			controller.abort();
		}

		/*
		 * Astro replaces the document on a client-side navigation. Cancel the
		 * loop, drop both observers, 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-starfield]").forEach(initStarfield);

	/* 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>
	.starfield {
		box-sizing: border-box;
		background: var(--sf-bg);
	}

	.starfield--section {
		position: relative;
		min-block-size: var(--sf-min-height);
		/* An isolated stacking context, so the canvas sits under this
		   component's own content and not under the host's. */
		isolation: isolate;
	}

	/*
	 * The whole-page backdrop. Fixed to the viewport and behind everything,
	 * which is the one job a background has.
	 *
	 * `z-index: -1` rather than a positive index under the content: it puts
	 * the field behind the page's normal flow without the page having to
	 * know it is there or give its own content a stacking order. The cost is
	 * that a host which makes `body` a stacking context — a transform, a
	 * filter, `isolation` — will trap it. That is noted in the README rather
	 * than worked around, because the workaround is worse than the note.
	 */
	.starfield--page {
		position: fixed;
		inset: 0;
		z-index: -1;
		pointer-events: none;
	}

	.sf-canvas {
		position: absolute;
		inset: 0;
		z-index: 0;
		inline-size: 100%;
		block-size: 100%;
		display: block;
		/* The field is scenery. It never takes a click meant for the content
		   above it, and it is out of the accessibility tree besides. */
		pointer-events: none;
	}

	.sf-content {
		position: relative;
		z-index: 1;
	}

	/*
	 * Nothing here needs a reduced-motion rule. The animation is a canvas
	 * loop rather than a transition, so the preference is read in script and
	 * the field is drawn once and left standing. A CSS rule could only hide
	 * it, which is the one outcome worth avoiding.
	 */

	@media (forced-colors: active) {
		/* The stars are painted pixels and forced colours cannot reach them,
		   so the backdrop steps aside rather than leaving grey dots on a
		   system background. */
		.sf-canvas {
			display: none;
		}

		.starfield {
			background: Canvas;
		}
	}
</style>

===== END src/components/Starfield.astro =====

===== FILE (supporting): src/components/Starfield.md =====
# Starfield

A perspective starfield flying toward the viewer, drawn on one canvas behind
whatever you put in front of it. Stars stream outward from a vanishing point,
growing and speeding up as they approach, and are recycled to the back when
they pass the camera.

Catalog page: `/components/starfield`

## Purpose

A backdrop. Either behind a hero's own content, or pinned behind the whole
page. It is scenery — it carries no content, takes no input, and is out of
the accessibility tree.

Use something else if the page already has a busy background or a photograph
behind its type. This is one more thing competing with the words, and it only
earns its place on a page that is otherwise quiet.

## Files required

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

- No imports at all, not even a type.
- No npm packages, no animation library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour and measurement is a `--sf-*` custom
  property on the component's own root.
- No webfonts, no images, and **no network requests of any kind**. The field
  is arithmetic.
- The only assumption about the host is that Astro compiles `.astro` files.

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

## Basic usage

Behind a hero, with its own content on top:

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

<Starfield minHeight="70vh">
  <div class="hero">
    <h1>Long range</h1>
    <p>Everything is further away than it looks.</p>
  </div>
</Starfield>
```

Behind the whole page:

```astro
<Starfield mode="page" />
```

In `"page"` mode it is fixed to the viewport at `z-index: -1` and the slot is
ignored — there is nothing for it to be inside. **One caveat worth knowing:**
a negative index sits behind the page's normal flow without the page having
to know it is there, but a host that makes `body` a stacking context — a
`transform`, a `filter`, `isolation`, `will-change` — will trap it behind
nothing. If the field vanishes in `"page"` mode, that is why; give the page
content `position: relative` and use `mode="section"` with the content in the
slot instead.

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

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `mode` | `"section" \| "page"?` | `"section"` | A block with its slot on top, or fixed behind the whole page. |
| `density` | `number?` | `12.8` | Stars per 100,000 square pixels. The reference's own figure. |
| `maxStars` | `number?` | `420` | Ceiling per population, so a very large screen stays cheap. |
| `anchoredDensity` | `number?` | `0` | A second population, in the same units, that ignores the camera. Added to `density`, not taken out of it. |
| `speed` | `number?` | `1` | Multiple of the reference's pace. |
| `focusX` | `number?` | `0.5` | Starting vanishing point, as a fraction of the width. Not a home — the pointer moves it for good. |
| `focusY` | `number?` | `0.25` | And of the height. The reference starts a quarter down, which is what tilts the field slightly downward. |
| `pointerTracking` | `boolean?` | `true` | Let the pointer steer the camera. |
| `pointerStrength` | `number?` | `1` | How hard it banks. `0.5` is a half turn. |
| `maxRadius` | `number?` | `3.2` | Radius of the largest stars, in CSS pixels. |
| `starColor` | `string?` | `#ffffff` | The stars. |
| `background` | `string?` | `#000000` | The ground. `"transparent"` to lay this over something else. |
| `minHeight` | `string?` | `"100svh"` | Height floor in `"section"` mode. Full screen by default; `svh` so a phone's collapsing address bar does not show a strip underneath. |
| `maxPixelRatio` | `number?` | `2` | Cap on the backing-store scale. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Element id. |

Every numeric prop is clamped rather than trusted — each one is multiplied
into a per-frame loop, where a `NaN` would stall the field and a negative
would run it backwards.

## The pointer

**The pointer steers the camera, and it keeps the heading you leave it on.**
Move the mouse and the camera banks toward it; stop, or move away, and it
stays on that heading. `focusX`/`focusY` is where the field *starts*, not
somewhere it returns to.

### It is a turn, not a nudge

The camera has **one velocity vector of fixed length**, and the pointer
rotates it. Aim at an edge and most of that speed becomes sideways travel, so
the field stops coming at you and sweeps across instead. Come back to the
middle and it flies straight ahead again.

That matters because the obvious implementations both fail, and they fail in
the way you can see:

- **Moving the point the field is drawn from** shifts every star by the same
  number of pixels. The picture slides; not one star changes heading.
- **Adding a sideways push on top of an unchanged forward speed** bends the
  paths a little, but the stars still fly at the lens the whole time, however
  the camera is aimed.

The reference settles it. Decomposing its motion frame by frame into
expansion, rotation and slide, its expansion and its slide are
**anti-correlated** — 4.89 px/frame of slide against 1.32 of expansion at one
moment, 0.39 against 3.78 at another, with the total roughly constant. Only a
velocity vector that rotates can do that: the forward speed has to give way
to the turn. Rotation is a flat 0.03 of expansion throughout, so there is no
swirl in it at all.

Matched on the rebuild: at full turn the expansion falls to **0.89 px/frame**
against the reference's **0.90**, rotation stays at 0.03, and a star's path
bends by a median of **21°** against the reference's **17°**.

### The anchored set

`anchoredDensity` adds a **second population that ignores the camera**. Those
stars fly straight at the viewer at full speed whatever the camera is doing —
they neither bank with it nor give up forward speed to a turn.

They exist because of what the turn costs. The camera trades forward speed
for sideways speed, so at full turn a field where every star steers has
almost nothing left coming toward you. Measured at a hard turn: with the
second set off, 29% of the field is still flying outward; with it at `5`
against a `density` of `12.8`, that rises to 47%.

They are **added to** `density` rather than carved out of it, so turning this
up makes the field busier rather than rebalancing it. They are drawn
identically — the difference is only in how they move — and they live in
their own array, so membership is the behaviour and there is no per-star flag
to re-roll when one recycles.

`0`, the default, is the reference's own behaviour: every star steers.

### Depth

Steering shifts the *world*, so a star moves on screen by `delta / z` — the
near ones swing several times as far as the distant ones. Measured with the
recession switched off, so steering is the only thing moving anything, the
nearest stars swing **3.0×** as far as the farthest, against the 3.3× the
depth range predicts.

The reference does **not** do this: its response is flat with depth, 1.02
near-to-far, measured with a proper `1/z` proxy rather than apparent size.
This is the one place the field is deliberately richer than its source.

### The rest of it

- **Mouse and pen only.** A touch drag is a scroll, not a look.
- **Nothing happens under reduced motion** — the field is frozen, and a
  frozen field that jumps when you wave at it would be worse than one that
  does not move.
- `pointerStrength` below `1` scales the turn.
- The listener is on `window`, not the canvas — in `"page"` mode the root is
  `pointer-events: none` and would never see a move. The root's own box is
  what decides whether a position counts.
- The camera settles on a new heading over a 0.18s time constant, eased in
  seconds rather than per frame so it behaves the same at 60Hz and 144Hz.

## How it works

Each star is a point in front of the camera: `x` and `y` either side of the
axis, `z` its distance. The screen position is `focus + x / z`, and that one
division is the whole effect. A star doubles its distance from the vanishing
point every time its depth halves, so the field streams radially and speeds
up toward the edges without any of that being animated separately.

Three details are worth knowing because each was a measurement rather than a
choice:

- **Brightness belongs to the star, not to its depth.** Tracking stars on the
  reference that barely move gives a mean brightness change of 0.77 out of
  255 — they do not brighten as they approach. So each star gets a fixed
  magnitude at birth, drawn skewed toward the dim end to match the
  reference's mean of 98/255.
- **The depth rate falls off as `z^1.5`.** At a constant rate the outer stars
  run three to four times the speed of the inner ones, which reads as a warp
  jump. This leaves screen speed going as `1 / sqrt(z)` — the same streaming
  at a more even pace.
- **A star that leaves the frame is recycled immediately.** Skipping the draw
  and keeping it in the array was the obvious version, and it quietly thinned
  the field: the count sat at its target while two thirds of it was outside
  the frame, so 103 stars showed up as 31.

## Cost

It is a permanent animation, so it is worth knowing what it costs and what
stops it.

- **It stops when scrolled away.** An IntersectionObserver with a 120px
  margin pauses the loop. Verified: 3,082 draws while on screen, **zero**
  while scrolled past, 2,992 after scrolling back.
- **It stops when the tab is hidden**, because `requestAnimationFrame` does.
- **It scales for the display**, capped at 2. The reference does not scale at
  all and is visibly soft on any retina screen.
- At the default density a 1440×900 box runs about 165 stars, which is one
  `arc` fill each per frame.
- The pointer listener is `passive` and does no work beyond storing two
  numbers; the easing happens in the frame loop that was already running.

Time between frames is clamped to 50ms, so a tab restored after a minute
resumes rather than teleporting the field forward by a minute of depth.

## Accessibility

- The canvas is `aria-hidden` and `pointer-events: none`. In `"page"` mode
  the root is `aria-hidden` too. There is no content here and nothing to
  reach.
- Under `forced-colors: active` the canvas is hidden and the root falls back
  to `Canvas`. Painted pixels do not respond to a forced palette, and grey
  dots on a system background are worse than none.

## Reduced motion

Under `prefers-reduced-motion: reduce` **the field is drawn once and left
standing.** It is decoration; a still starfield still reads as one, where an
empty box would be a hole in the design.

This is handled in script rather than CSS, because the animation is a canvas
loop and not a transition — there is no duration for a media query to
collapse. The preference is also watched, so turning it on mid-session stops
the loop rather than waiting for a reload.

## Without JavaScript

**The field does not render.** It is a canvas drawn by script; there is no
markup fallback and none is pretended.

What does survive is everything around it: in `"section"` mode the slot
content renders normally against the background colour, so a hero keeps its
heading, its text and its layout — just on a plain ground. Verified with
scripting disabled: slot visible, background painted, no overflow, no gap
where the canvas would be.

If the field is load-bearing for a page's look, give `background` a value you
are happy to be seen on its own.

## Sizing

The canvas fills its box and is re-measured by a ResizeObserver, so it
follows a fluid container rather than the viewport. Star count follows the
box's area, so a phone and a wide monitor get the same visual density instead
of the phone looking empty.

Retune from CSS without touching the file:

```css
.my-hero .starfield {
  --sf-bg: #05060d;
  --sf-star: #cfe4ff;
  --sf-min-height: 100svh;
}
```

## Differences from the reference

Rebuilt from a live page and a screen recording, measured rather than copied.
A perspective starfield is one of the oldest graphics demos there is; nothing
of the reference's code is reproduced.

Matched, measured at the recording's own 1866×798:

| | Reference | This |
| :--- | :--- | :--- |
| Vanishing point | 0.5 / 0.25 | 0.5 / 0.25 |
| Mean speed | 3.35 px/frame | 3.05 |
| Large vs small star speed | 1.23× | 1.20× |
| Brightness change per star | 0.77/255 | 0.55 |
| Density | 12.8 per 100k px² | 12.8 |
| Star colour | neutral white, 27–255 | same |

Two things differ, and they are the same difference twice: **this field is
more strictly radial than the reference's.** Its motion aligns with the
vanishing point at 0.99 against the reference's 0.66, and its outer quarter
runs 3.4× the speed of its inner quarter against 1.35.

Part of that is measurement. The reference's figures come from a compressed
30fps recording, and centroid jitter both scatters the directions and puts a
floor under the slow stars. Adding matching jitter to this field's own
measurements moves it the right way — alignment 0.99 → 0.84, ratio 3.4 → 2.4
at ±1.4px — but not all the way, so some of the gap is real. Each star is
given a little proper motion to account for it; pushing that further closed
the alignment but broke the size-to-speed relationship that was already
matching, so it was left where the other four measurements stay good.

Also different:

- **It scales for device pixel ratio.** The reference does not.
- **Pointer tracking can be turned off.**
- **It stops when scrolled off screen.** The reference runs for as long as the
  page is open.
- **It honours reduced motion.**
- **It is a component with a slot**, rather than a bare full-viewport canvas.

===== END src/components/Starfield.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-19 at 1920, 1866, 1440, 1280, 1024, 768, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:da9af4a5d0728d36d9651dbbbd5beef3dd44fae91c8bd513271490029e32f998 — 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
“Starfield” from my Astro Component Library:
https://astro.baysixmedia.com/components/starfield/

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
2026-09-19
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-19 at 1920, 1866, 1440, 1280, 1024, 768, 390, 320px

  • Full-page contextpass
  • Keyboard — declared to contain nothing focusablenot-applicable
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecyclepass
42 verification notes
  • Every check below was run against the production build served by `astro preview`, not the dev server.
  • Rebuilt from a live page and a 30fps screen recording, measured rather than copied. A perspective starfield is one of the oldest graphics demos there is and nothing of the reference's code is reproduced; the reference itself is a single full-bleed canvas with no DOM, no text and no images, which was read live before measuring.
  • Matched on five of seven measurements, all taken at the recording's own 1866x798 so the two sets are directly comparable. Vanishing point 0.5/0.25 against the reference's 0.5/0.25. Mean screen speed 3.05 px/frame against 3.35. Largest stars run 1.20x the speed of the smallest against 1.23. Mean brightness change on a tracked star 0.55/255 against 0.77, so stars hold their brightness rather than twinkling. Density 12.8 stars per 100,000 square pixels, which is the reference's own figure. Stars are neutral white at varying alpha - measured channel spread 0.1 - over pure black.
  • Two measurements do not match, and they are the same difference twice: this field is more strictly radial than the reference's. Motion aligns with the vanishing point at 0.99 against 0.66, and the outer quarter of the field runs 3.4x the speed of the inner quarter against 1.35.
  • That gap was investigated rather than accepted or tuned away. Part of it is measurement: the reference's figures come from a compressed recording, where centroid jitter both scatters the direction of a short motion vector and puts a floor under the slowest stars. Tested directly by re-measuring this field's OWN output through the same statistics with synthetic centroid noise added - at plus or minus 1.4px the alignment falls 0.99 to 0.84 and the radius ratio 3.4 to 2.4, moving the right way on both but not all the way. So some of the gap is real.
  • Three tuning passes were spent on the remainder and the configuration was deliberately reverted. Scaling the depth rate by z^1.5 was kept, because it evens the pace without touching anything else. Narrowing the depth range was tried and made it worse - stars recycled sooner so more of the field sat at the fast edge, and the ratio went from 2.8 to 3.4. Raising each star's proper motion far enough to reach the reference's alignment broke the size-to-speed relationship that was already matching, taking it from 1.20 to 0.64 and then 0.97. The build was returned to the setting where five of seven measurements are close, and the remaining difference is recorded here rather than hidden.
  • A real defect was found and fixed during the build. A star that flew out of the frame was skipped at draw time but kept in the array, so the field silently thinned: the star count sat at its target of 103 while two thirds of them were outside the frame, and only 31 were ever visible. Recycling on exit as well as on passing the camera fixed it - measured 98 to 100 visible against a target of 103.
  • The vanishing point follows the pointer, which is the reference's behaviour and was measured rather than assumed. Fitting a least-squares vanishing point to the reference's motion in ten-frame windows puts it between 0.34 and 0.73 across the width and between 0.07 and 0.84 down the height over a fourteen-second recording, and the field's mean drift inverts with it - +5.31 px/frame downward when the focus sits at 0.065, -3.79 upward when it sits at 0.843, with the same relationship on the horizontal axis. A static focus cannot produce that.
  • CORRECTED, twice. The first implementation moved the point the field is drawn from, and the user reported the real defect: the dots kept going straight instead of changing direction. That was right and the geometry says so - moving the projection origin translates every star by the same number of pixels, so each one carries on along the heading it already had and the picture merely slides. Steering now shifts the WORLD instead, which moves a star on screen by delta over z, so near stars swing several times as far as distant ones and every trajectory bends.
  • CORRECTED A THIRD TIME, and this is the one that matters. Steering as an extra sideways push on top of an unchanged forward speed still had the stars flying at the lens however the camera was aimed - the user's words, and they were right. Decomposing the reference frame by frame into expansion, rotation and slide shows why: its expansion and its slide are ANTI-CORRELATED, 4.89 px/frame of slide against 1.32 of expansion at one moment and 0.39 against 3.78 at another, with the total roughly constant. No additive push can produce that. The camera now carries ONE velocity vector of fixed length which the pointer rotates, so forward speed gives way to sideways speed as the turn steepens.
  • Matched on the quantity that distinguishes the two models. At full turn the rebuild keeps 0.89 px/frame of expansion against the reference's 0.90, and at rest it returns to 2.13 against 0.92 of slide. Rotation is 0.03 of expansion on the rebuild against 0.03 on the reference, so neither field swirls. The rebuild's absolute slide reads lower only because slide scales with the screen - 3.66 px/frame at 1280 wide is 5.3 at the recording's 1864, against 5.8 measured.
  • Path bend, which is the visible consequence, was tuned against the reference directly: tracking stars over fourteen frames while the camera moves, the reference turns them by a median of 17 degrees. The first steering attempt managed 3.8 and read as flying dead ahead; the camera throw was then set by measurement, giving 21 degrees. With the pointer still, bend falls to 0.95 degrees - the field flies straight when it is not being aimed.
  • A second population was added at the user's request: `anchoredDensity` stars that ignore the camera entirely, flying straight at the viewer at full speed whatever the turn is doing. It exists because of what the turn costs - the camera trades forward speed for sideways speed, so at full turn a field where every star steers has almost nothing left coming toward the viewer.
  • Measured by isolating it on one page: with the second set off, a hard turn leaves 29 per cent of the field still flying outward; with it at 5 against a density of 12.8, that rises to 47 per cent, and the star count goes from 114 to 149. It ADDS to the first population rather than being carved out of it, which the count confirms. Straight ahead both configurations sit at 97 per cent outward, so the set changes nothing when the camera is not turning.
  • The two populations are separate arrays rather than one array with a flag. Membership is the behaviour, so there is no per-star branch to get wrong and nothing to re-roll when a star recycles past the camera. They are drawn in two passes rather than over a merged array, because a joined array would be a fresh allocation every frame for as long as the page is open.
  • Parallax measured in isolation rather than inferred. With recession switched off (speed 0) every pixel of motion is steering, and the star speeds then spread from the 10th to the 90th percentile by a factor of 3.0 against the 3.33 the depth range of 0.3 to 1 predicts. An earlier attempt to show this used cluster size as a depth proxy and read only 1.42, because size here comes mostly from a star's own magnitude rather than from its depth - the proxy was weak, not the parallax.
  • The field turns, measured directly: mean lateral velocity runs +0.03 px/frame with no pointer, +2.61 steering one way and -2.27 the other, and mean vertical velocity goes 1.34 to 3.35 steering downward.
  • The sign of the steer was wrong on the first pass and is recorded because it is easy to get backwards. Steering is the camera's own motion, so the world sweeps the opposite way; taking the sign the obvious way round sent the stars toward the cursor with the vanishing point fleeing to the far corner. Negated, a pointer at (0.82, 0.80) puts the apparent vanishing point at (0.683, 0.559) and one at (0.18, 0.22) puts it at (0.313, 0.009) - under the cursor, which is what the reference does.
  • The turn is partial even at full strength, which is closer to the reference than placing the vanishing point directly was. The pointer at an edge moves the apparent focus to roughly 0.31 to 0.71 across the width, against the 0.34 to 0.73 measured on the reference over fourteen seconds.
  • The focus eases rather than snapping, on a 0.18s time constant, and the easing is exponential in SECONDS rather than a fixed fraction per frame - so it settles at the same pace on a 60Hz panel and a 144Hz one instead of arriving nearly twice as fast on the latter. The reference's own lag reads as roughly a third of a second for half the height of the frame, part of which is the ten-frame window used to measure it, so this sits at the responsive end of what the recording supports.
  • CORRECTED. An earlier pass eased the focus back to focusX/focusY whenever the pointer left the box, and recorded that as the behaviour. That was wrong for this component: the user asked for the field to hold the angle it was last given, and springing home the moment the pointer leaves reads as the field rejecting you rather than having been aimed. Both reset paths were removed - the out-of-box branch of the move handler and the document pointerleave listener - so focusX/focusY is now where the field STARTS and not somewhere it returns to.
  • Holding verified across four situations, re-measured after the change to steering. Before any pointer movement the apparent vanishing point sits at (0.502, 0.252) against a declared start of (0.5, 0.25). With the pointer at (0.82, 0.80) it reads (0.683, 0.559); after the pointer sits still for two and a half seconds, (0.679, 0.554); after a pointerleave on the document, (0.706, 0.598) - held on that heading, and demonstrably not back at the start. Re-aimed at (0.18, 0.22) it reads (0.313, 0.009) and holds (0.289, -0.005) through a second leave, so it keeps the newest heading rather than any remembered one.
  • `pointerTracking: false` is honoured, checked behaviourally and not just as an attribute. The fixture's second instance declares tracking off and a focus of 0.85/0.6; with the pointer parked at a tenth across and a tenth down ITS OWN box, its measured focus stayed at (0.785, 0.55). The first instance, tracking on, sat at (0.503, 0.28) against its declared rest of 0.5/0.25 because the pointer was outside its box. Both behaved as declared in the same frame.
  • Touch pointers are ignored, by pointerType, because a touch drag is a scroll rather than a look. Under a motion preference the handler returns before doing anything: verified that moving the pointer across a reduced-motion field leaves the canvas byte-identical, same lit-pixel count and same arrangement.
  • The listener is on the window rather than on the canvas, and the root's own bounding box decides whether a position counts. In page mode the root is `pointer-events: none` and would never receive a move of its own; in section mode this is also what lets the field notice that the pointer has left rather than only where it is. It is registered passive and stores two numbers - the easing happens in the frame loop that was already running.
  • Full screen by default. `minHeight` is 100svh rather than a fraction of the viewport, so a field dropped in with no props fills the screen the way the reference does, and `svh` rather than `vh` so a phone with a collapsing address bar does not show a strip of page underneath it. Shorter heroes pass their own value; the whole-page backdrop uses mode page instead and ignores the property.
  • Pauses when scrolled off screen, which the reference does not. An IntersectionObserver with a 120px margin stops the loop. Measured by counting canvas draw calls per instance: 3082 while on screen, ZERO while scrolled past, 2992 after scrolling back. The first attempt at this test summed both fixture instances and reported a false failure, because scrolling one out of view brings the other in.
  • It also stops when the tab is hidden, which requestAnimationFrame does for free. Confirmed incidentally on the reference during review: with document.hidden true, zero rAF callbacks fire in 500ms and its canvas never even sizes itself off the 300x150 default.
  • Scales for the display, capped at 2. Measured at a device pixel ratio of 2: a 1440px CSS box gets a 2880px backing store. The reference leaves the canvas at CSS pixels and is visibly soft on any retina screen.
  • Reduced motion freezes the field rather than hiding it. Measured: 973 lit pixels on the first frame and the same 973 in the identical arrangement 1.2 seconds later. Handled in script rather than CSS, because a canvas loop has no duration for a media query to collapse, and the preference is watched so turning it on mid-session stops the loop without a reload. Freezing rather than hiding is a judgement: the component is decoration, a still starfield still reads as one, and an empty box would be a hole in the design.
  • Without JavaScript the field does not render, and that is stated rather than worked around - it is a canvas drawn by script and there is no markup fallback. What survives is everything around it: verified with scripting disabled, the slot content renders and is visible, the background is painted, the canvas sits unsized at its 300x150 default with nothing drawn, and there is no page overflow and no gap where the field would be. A hero keeps its heading, its text and its layout on a plain ground.
  • Keyboard is recorded not-applicable and the declaration licenses it: the component contributes nothing focusable. The canvas is aria-hidden and takes no pointer events, and in page mode the root is aria-hidden as well. The only focusable things on the fixture belong to the slot content, not to the component.
  • Responsive at six sizes from 1920x1080 down to 320x640, portrait and landscape. The canvas is re-measured by a ResizeObserver so it follows its own box rather than the viewport, and the star count follows the box area - 1203 lit pixels at 1440 wide down to 187 at 320, which is the same visual density rather than a phone that looks empty. `scrollWidth === clientWidth` at every size and the slot content stays visible at all of them.
  • Two instances on one page are independent and were checked as such: the second runs at 0.45 speed, double the density, a corner vanishing point at 0.85/0.6 and a different palette. Both booted, both sized their own canvas to their own box (1280x446 and 1280x302), both drew. Zero duplicate ids, no page overflow.
  • Lifecycle measured by counting draw calls rather than by inspecting attributes. Running, the two instances draw about 4300 arcs a second. After `astro:before-swap` that falls to ZERO, the ready flag is gone and both canvases have had their width and height attributes removed, which is the served state. `astro:page-load` boots them again and the rate returns to 4278 a second; dispatching it a second time leaves it at 4283 rather than doubling, so a double boot does not add a second loop.
  • Client navigation is recorded end-to-end-passed on the strength of a real <ClientRouter /> round trip rather than the dispatched events above - the two are different claims and only the second certifies. A link was followed to the destination route and the browser navigated back: both instances returned, both carried the ready flag, both had re-sized their canvas past the unsized default, and the field was still drawing. Nothing was written to document.body.
  • Forced colours hide the canvas and fall the root back to `Canvas`. Painted pixels do not answer a forced palette, so the alternative is grey dots on a system background. Declared from the stylesheet rather than observed - no forced-colors emulation was available in this session.
  • Frame timing is clamped to 50ms, so a tab restored after a minute resumes rather than advancing the field by a minute of depth in one step.
  • Install kit confirmed from the component's own page: one required file carried inline with its destination path, plus the README as supporting. The component declares no assets because it has none - the field is arithmetic, and it issues no network request of any kind.
  • Zero console messages and zero page errors across the preview route, the fixture route and the ClientRouter route.
  • The demo copy in the slot is original to this library. The reference's canvas carries no text to reproduce.
  • Chromium only, and driven directly rather than through this session's usual browser tooling - the Playwright MCP server wedged on its own profile lock partway through the review and would not release it, and the in-app browser pane stays document.hidden, where requestAnimationFrame never fires and a canvas component cannot be observed at all. No WebKit or Gecko build was available and no claim is made about either.

Open the verification fixtureOpen the ClientRouter fixture

src/components/library/Starfield.astro

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

---
/**
 * Starfield
 *
 * A perspective starfield flying toward the viewer, drawn on one canvas
 * behind whatever you put in the slot. Stars stream outward from a vanishing
 * point, accelerating and growing as they approach, and are recycled to the
 * back when they pass the camera.
 *
 * It is a backdrop, not a picture: the canvas is `aria-hidden`, takes no
 * pointer events, and sits under the content rather than beside it.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image. Every colour and measurement is a `--sf-*` custom property.
 */

interface Props {
	/**
	 * Stars per 100,000 square pixels. Density rather than a count, so a wide
	 * screen does not end up sparser than a narrow one. The reference runs at
	 * about 12.8.
	 */
	density?: number;
	/** Hard ceiling on the star count, so a very large screen stays cheap. */
	maxStars?: number;
	/**
	 * How fast the field comes at you, as a multiple of the reference's pace.
	 * `1` crosses the full depth in about six and a half seconds.
	 */
	speed?: number;
	/**
	 * Where the stars stream from before the pointer has moved, as fractions
	 * of the box. The reference starts on the horizontal centre and a quarter
	 * of the way down, which is what tilts the field slightly downward.
	 *
	 * A starting point, not a home: once the pointer has aimed the field it
	 * stays aimed.
	 */
	focusX?: number;
	focusY?: number;
	/**
	 * Let the vanishing point follow the pointer.
	 *
	 * The field streams out of wherever the cursor is, and holds that angle
	 * once the pointer stops or leaves — `focusX`/`focusY` is where it
	 * starts, not somewhere it returns to. Mouse and pen only; a touch drag
	 * is a scroll, not a look.
	 */
	pointerTracking?: boolean;
	/**
	 * How hard the camera banks toward the pointer. `0.5` is a half turn.
	 */
	pointerStrength?: number;
	/**
	 * A second population, in stars per 100,000 square pixels, that ignores
	 * the camera.
	 *
	 * These fly straight at the viewer at full speed whatever the camera is
	 * doing — they neither bank with it nor give up forward speed to a turn.
	 * They are added to `density` rather than taken out of it, so raising
	 * this makes the field busier.
	 *
	 * A few of them anchor the picture: with every star steering, a hard
	 * turn leaves nothing at all coming toward the viewer. `0` is the
	 * reference's own behaviour.
	 */
	anchoredDensity?: number;
	/** Radius of the nearest stars, in CSS pixels. The farthest are ~0.35. */
	maxRadius?: number;
	/** The stars. Any CSS colour; alpha is applied on top by depth. */
	starColor?: string;
	/** The ground behind them. `"transparent"` to lay this over something. */
	background?: string;
	/**
	 * How it sits in the page.
	 *
	 * `"section"` is a block in the flow that its own slot content sits on
	 * top of — a hero with a field behind it. `"page"` pins it to the
	 * viewport behind everything, which is the whole-page backdrop; the slot
	 * is ignored there, because there is nothing for it to be inside.
	 */
	mode?: "section" | "page";
	/**
	 * Height floor in `"section"` mode when the slot gives it none.
	 *
	 * Full screen by default, because that is what a field like this is
	 * for. `svh` rather than `vh` so a phone's collapsing address bar does
	 * not leave a strip of page showing under it.
	 */
	minHeight?: string;
	/**
	 * Cap on the backing-store scale. 2 is plenty; the reference does not
	 * scale at all and is visibly soft on any retina display.
	 */
	maxPixelRatio?: number;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

const {
	density = 12.8,
	maxStars = 420,
	speed = 1,
	focusX = 0.5,
	focusY = 0.25,
	pointerTracking = true,
	pointerStrength = 1,
	anchoredDensity = 0,
	maxRadius = 3.2,
	starColor = "#ffffff",
	background = "#000000",
	mode = "section",
	minHeight = "100svh",
	maxPixelRatio = 2,
	class: className,
	id,
} = Astro.props;

/* Clamped rather than trusted: each of these is multiplied into a per-frame
   loop, and a NaN or a negative would either stall the field or spin it. */
const num = (value: unknown, fallback: number, low: number, high: number) => {
	const n = Number(value);
	return Number.isFinite(n) ? Math.min(high, Math.max(low, n)) : fallback;
};

const safe = {
	density: num(density, 12.8, 0, 80),
	maxStars: Math.round(num(maxStars, 420, 1, 4000)),
	speed: num(speed, 1, 0, 8),
	focusX: num(focusX, 0.5, -1, 2),
	focusY: num(focusY, 0.25, -1, 2),
	pointerStrength: num(pointerStrength, 1, 0, 1),
	anchoredDensity: num(anchoredDensity, 0, 0, 80),
	maxRadius: num(maxRadius, 3.2, 0.5, 12),
	maxPixelRatio: num(maxPixelRatio, 2, 1, 4),
};

const style = [
	`--sf-star:${starColor}`,
	`--sf-bg:${background}`,
	`--sf-min-height:${minHeight}`,
].join(";");
---

<div
	class:list={["starfield", `starfield--${mode === "page" ? "page" : "section"}`, className]}
	id={id}
	style={style}
	data-starfield
	aria-hidden={mode === "page" ? "true" : undefined}
	data-sf-density={String(safe.density)}
	data-sf-max-stars={String(safe.maxStars)}
	data-sf-speed={String(safe.speed)}
	data-sf-focus-x={String(safe.focusX)}
	data-sf-focus-y={String(safe.focusY)}
	data-sf-max-radius={String(safe.maxRadius)}
	data-sf-pointer={pointerTracking ? "1" : "0"}
	data-sf-pointer-strength={String(safe.pointerStrength)}
	data-sf-anchored={String(safe.anchoredDensity)}
	data-sf-max-dpr={String(safe.maxPixelRatio)}
>
	{/*
	  Decoration, and marked as such twice over: out of the accessibility tree
	  and out of the way of the pointer. Nothing here is content.
	*/}
	<canvas class="sf-canvas" data-sf-canvas aria-hidden="true"></canvas>
	{mode !== "page" && <div class="sf-content"><slot /></div>}
</div>

<script>
	/*
	 * The field.
	 *
	 * Each star is a point in front of the camera: `x` and `y` in world units
	 * either side of the axis, `z` its distance, 1 at the back and ~0.08 at
	 * the eye. Projection is the one division that makes it look like depth —
	 * screen offset is `x / z`, so a star doubles its distance from the
	 * vanishing point every time its depth halves.
	 *
	 * Everything the reference shows falls out of that single rule rather
	 * than being animated separately: stars stream radially, they accelerate
	 * as they near the camera, and the nearer ones are both larger and faster
	 * than the far ones. Measured on the reference, the outer quarter of the
	 * field moves 1.35x as fast as the inner quarter, and the largest stars
	 * 1.23x as fast as the smallest.
	 */
	interface Star {
		x: number;
		y: number;
		z: number;
		/**
		 * The star's own brightness, fixed for its life.
		 *
		 * Not derived from depth, and that is a measurement rather than a
		 * preference: tracking stars that barely move on the reference gives
		 * a mean brightness change of 0.77 out of 255, so a star does not
		 * brighten as it approaches. Depth drives where it is and how fast;
		 * how bright it is belongs to the star.
		 */
		mag: number;
		/**
		 * The star's own sideways drift, fixed for its life.
		 *
		 * Without it the field is perfectly radial — every star on a line
		 * straight out from the vanishing point. Measured, the reference is
		 * not: its motion aligns with its focus at 0.66, where a pure
		 * projection gives 0.98. A touch of proper motion per star is what
		 * accounts for the difference, and it does a second job — it lifts
		 * the slow stars near the focus, which is what stops the field
		 * reading as a warp jump at the edges.
		 */
		dx: number;
		dy: number;
	}

	interface Instance {
		destroy(): void;
	}

	/*
	 * Depth at the eye and at the back of the field. A star that crosses
	 * Z_NEAR has passed the camera and goes to the back with a new position.
	 */
	const Z_NEAR = 0.3;
	const Z_FAR = 1;
	/* Depth per second at the back of the field, tuned to the reference's
	   measured mean of roughly 78 screen pixels per second at 1440 wide. */
	const Z_RATE = 0.42;
	/*
	 * How the depth rate falls off as a star approaches.
	 *
	 * A constant rate is the textbook fly-through. Screen speed goes as
	 * `r / z` and `r` itself goes as `1 / z`, so the field accelerates hard
	 * at the edges. Scaling the rate by `z^1.5` leaves screen speed going as
	 * `1 / sqrt(z)`, which is the same radial streaming at a more even pace.
	 *
	 * It does not fully close the gap to the reference, which measures a
	 * 1.35 spread between its inner and outer quarters against 3.4 here, and
	 * the honest reading is that some of that gap is the measurement rather
	 * than the design — see the verification notes. Narrowing the depth range
	 * was the other obvious lever and it made things worse: stars recycled
	 * sooner, so more of the field sat out at the fast edge.
	 */
	const Z_FALLOFF = 1.5;
	/* Stars are seeded across a box wider than the viewport so they arrive
	   from beyond the edges instead of appearing at them. */
	const MARGIN = 1.35;
	/* Spread of each star's own drift, in world units per second. */
	const DRIFT = 0.11;
	/*
	 * How quickly the camera settles into a new heading, as a time constant
	 * in seconds.
	 *
	 * Measured off the reference: the apparent vanishing point crosses
	 * roughly half the height of the frame in a third of a second, and it
	 * lags rather than snapping. Part of that measured lag is the ten-frame
	 * window used to read it, so this sits at the responsive end of what the
	 * recording supports.
	 */
	const STEER_TAU = 0.18;
	/*
	 * The camera's maximum turn, as the tangent of the angle off dead ahead
	 * with the pointer at an edge.
	 *
	 * This is a TURN, not a nudge, and that distinction is the whole of it.
	 * The camera has one velocity vector of fixed length; the pointer
	 * rotates it. Point at the edge and most of that speed becomes sideways
	 * travel, so the field stops coming at you and sweeps across instead.
	 *
	 * The reference does exactly this, and the evidence is that its
	 * expansion and its sideways slide are ANTI-CORRELATED: 4.89 px/frame of
	 * slide against 1.32 of expansion at one moment, 0.39 against 3.78 at
	 * another, with the total roughly constant. Adding a sideways push on
	 * top of an unchanged forward speed cannot produce that — the forward
	 * speed has to give way to the turn, which is what normalising the
	 * velocity vector below does.
	 */
	const STEER_THROW = 2.8;
	/*
	 * Tuned on the expansion the field keeps at full turn: the reference
	 * falls to 0.90 px/frame of expansion at its most sideways moment and
	 * this lands on 0.89. Its slide reads lower here only because slide
	 * scales with the screen - 3.66 at 1280 wide is 5.3 at the recording's
	 * 1864, against 5.8 measured.
	 */

	function initStarfield(root: HTMLElement): Instance | undefined {
		/* Idempotent: booting twice is a no-op, not a second loop. */
		if (root.dataset.sfReady === "") return undefined;

		const canvas = root.querySelector<HTMLCanvasElement>("[data-sf-canvas]");
		if (!canvas) return undefined;
		const ctx = canvas.getContext("2d");
		if (!ctx) return undefined;

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

		const read = (name: string, fallback: number) => {
			const value = Number(root.dataset[name]);
			return Number.isFinite(value) ? value : fallback;
		};
		const density = read("sfDensity", 12.8);
		const maxStars = read("sfMaxStars", 420);
		const speed = read("sfSpeed", 1);
		const focusX = read("sfFocusX", 0.5);
		const focusY = read("sfFocusY", 0.25);
		const maxRadius = read("sfMaxRadius", 3.2);
		const pointerStrength = read("sfPointerStrength", 1);
		const anchoredDensity = read("sfAnchored", 0);
		const tracking = root.dataset.sfPointer !== "0";
		const maxDpr = read("sfMaxDpr", 2);

		const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");

		let stars: Star[] = [];
		/*
		 * The second population, kept in its own array rather than flagged
		 * inside the first. Membership IS the behaviour, so there is no
		 * per-star branch to get wrong and no flag to re-roll when a star
		 * recycles.
		 */
		let anchors: Star[] = [];
		let width = 0;
		let height = 0;
		let scale = 1;
		let projection = 1;
		let frame: number | undefined;
		let last = 0;
		let visible = true;
		/*
		 * How hard the camera is steering, and where it is being asked to.
		 * Both start at zero, so the field flies straight until the pointer
		 * says otherwise.
		 */
		let steerX = 0;
		let steerY = 0;
		let aimX = 0;
		let aimY = 0;

		/**
		 * Brightness, skewed toward the dim end.
		 *
		 * A uniform draw gives a field of headlights. The reference's stars
		 * average 98 out of 255 with the full range represented, which is
		 * what this exponent reproduces.
		 */
		const magnitude = () => 0.12 + 0.88 * Math.pow(Math.random(), 1.8);

		/**
		 * Place a star at a given depth, spread evenly across the view *at
		 * that depth*.
		 *
		 * Working back from a screen position rather than picking world
		 * coordinates directly is what keeps the field even. Pick `x` and `y`
		 * in world units instead and the far stars bunch around the vanishing
		 * point while the near ones are nearly all off-screen.
		 */
		function place(star: Star, z: number) {
			star.z = z;
			star.x = ((Math.random() - 0.5) * width * MARGIN * z) / projection;
			star.y = ((Math.random() - 0.5) * height * MARGIN * z) / projection;
			star.dx = (Math.random() - 0.5) * DRIFT;
			star.dy = (Math.random() - 0.5) * DRIFT;
		}

		function spawn(z: number): Star {
			const star: Star = { x: 0, y: 0, z, mag: magnitude(), dx: 0, dy: 0 };
			place(star, z);
			return star;
		}

		/**
		 * Size the backing store to the box.
		 *
		 * The reference leaves the canvas at CSS pixels, so on any display
		 * with a ratio above 1 every star is resampled and the field goes
		 * soft. This matches the device, capped — past 2 the extra fill costs
		 * more than it shows.
		 */
		function resize() {
			const box = canvas!.getBoundingClientRect();
			if (box.width < 1 || box.height < 1) return;
			const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);
			width = box.width;
			height = box.height;
			scale = dpr;
			canvas!.width = Math.round(width * dpr);
			canvas!.height = Math.round(height * dpr);
			/* Tied to the smaller side so the field does not stretch when the
			   box is wide and short. Needed before any star is placed. */
			projection = Math.min(width, height) * 0.5;

			/*
			 * Density, not a fixed count, so the field reads the same on a
			 * phone and on a wide monitor instead of thinning out.
			 */
			const fill = (list: Star[], perArea: number) => {
				const target = Math.min(
					maxStars,
					Math.max(0, Math.round((width * height * perArea) / 100000)),
				);
				if (list.length > target) list.length = target;
				while (list.length < target) {
					/* Seeded across the whole depth, not all at the back, so
					   the field is already full on the first frame. */
					list.push(spawn(Z_NEAR + Math.random() * (Z_FAR - Z_NEAR)));
				}
			};
			fill(stars, density);
			fill(anchors, anchoredDensity);
			draw();
		}

		function draw() {
			if (!ctx || width < 1) return;
			ctx.setTransform(scale, 0, 0, scale, 0, 0);
			ctx.clearRect(0, 0, width, height);

			const fx = width * focusX;
			const fy = height * focusY;
			const colour = getComputedStyle(root).getPropertyValue("--sf-star").trim() || "#fff";
			ctx.fillStyle = colour;

			/*
			 * Both populations, a pass each rather than a merged array.
			 * They look identical and only move differently, and this runs
			 * every frame for as long as the page is open — no reason to
			 * allocate a joined array sixty times a second to save a loop.
			 */
			const render = (paint: Star[]) => {
			for (const star of paint) {
				const sx = fx + (star.x / star.z) * projection;
				const sy = fy + (star.y / star.z) * projection;

				/*
				 * A star that has left the box is sent to the back now rather
				 * than left in the array. Skipping the draw and keeping it
				 * was the obvious version and it quietly thinned the field:
				 * the count stayed at its target while two thirds of it sat
				 * outside the frame, so 103 stars showed up as 31.
				 */
				if (sx < -12 || sx > width + 12 || sy < -12 || sy > height + 12) {
					place(star, Z_FAR);
					star.mag = magnitude();
					continue;
				}

				/* Size leans on depth, brightness does not — see `mag`. */
				const near = (Z_FAR - star.z) / (Z_FAR - Z_NEAR);
				const radius = Math.max(
					0.4,
					maxRadius * (0.35 + 0.65 * star.mag) * (0.55 + 0.45 * near),
				);

				ctx.globalAlpha = star.mag;
				ctx.beginPath();
				ctx.arc(sx, sy, radius, 0, Math.PI * 2);
				ctx.fill();
			}
			};

			render(stars);
			render(anchors);
			ctx.globalAlpha = 1;
		}

		function advance(dt: number) {
			const step = Z_RATE * speed * dt;
		/*
		 * Steering is applied to the WORLD, not to where the field is drawn
		 * from, and that distinction is the whole point of it.
		 *
		 * Moving the projection origin instead translates every star by the
		 * same number of pixels, so each one carries on along the heading it
		 * already had — the field slides sideways and nothing changes
		 * direction. Shifting the world shifts a star on screen by
		 * `delta / z`, so a near star swings several times as far as a
		 * distant one and every trajectory bends. That is parallax, and it
		 * is what makes the field read as a camera turning rather than a
		 * picture being dragged.
		 */
			/*
			 * One velocity vector, of fixed length, pointing where the
			 * camera is aimed. Normalising is what trades forward speed for
			 * sideways speed as the turn steepens — at full throw most of
			 * the travel is lateral and the field barely expands at all.
			 */
			const tilt = Math.hypot(steerX, steerY);
			const norm = 1 / Math.sqrt(1 + tilt * tilt);
			const forward = step * norm;

			/**
			 * Move one population.
			 *
			 * `lateral` is what separates the two: the steered set travels
			 * sideways by the tangent of the turn and pays for it out of its
			 * forward speed, while the anchored set gets the full step and
			 * no sideways travel at all.
			 */
			const move = (list: Star[], depthStep: number, lateral: number) => {
				for (const star of list) {
					star.z -= depthStep * Math.pow(star.z, Z_FALLOFF);
					star.x += star.dx * dt + steerX * lateral;
					star.y += star.dy * dt + steerY * lateral;
					if (star.z > Z_NEAR) continue;
					/* Past the camera: back of the field with a new position
					   and a new brightness. Reused in place, so the array
					   length and the allocation stay flat for the life of the
					   page. */
					place(star, Z_FAR);
					star.mag = magnitude();
				}
			};

			move(stars, forward, forward);
			/* Full speed, straight ahead, whatever the camera is doing. */
			move(anchors, step, 0);
		}

		/**
		 * Ease the camera toward the heading it has been given.
		 *
		 * Frame-rate independent: the exponential is in seconds, so the
		 * field settles at the same pace on a 60Hz panel and a 144Hz one
		 * rather than arriving nearly twice as fast on the latter.
		 */
		function easeSteer(dt: number) {
			const k = 1 - Math.exp(-dt / STEER_TAU);
			steerX += (aimX - steerX) * k;
			steerY += (aimY - steerY) * k;
		}

		function tick(now: number) {
			frame = window.requestAnimationFrame(tick);
			/* Clamped: a tab restored after a minute must not teleport the
			   field forward by a minute's worth of depth. */
			const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016);
			last = now;
			easeSteer(dt);
			advance(dt);
			draw();
		}

		function stop() {
			if (frame !== undefined) window.cancelAnimationFrame(frame);
			frame = undefined;
			last = 0;
		}

		function run() {
			stop();
			/*
			 * Frozen rather than hidden under a motion preference. The field
			 * is decoration; a still starfield still reads as one, where an
			 * empty box would just be a hole in the design.
			 */
			if (reduced.matches || !visible) {
				draw();
				return;
			}
			frame = window.requestAnimationFrame(tick);
		}

		/*
		 * requestAnimationFrame already parks itself when the tab is hidden.
		 * What it does not know is whether the field is still on screen, so a
		 * page that scrolls past it would keep painting for nothing.
		 */
		const observer = new IntersectionObserver(
			(entries) => {
				visible = entries.some((entry) => entry.isIntersecting);
				run();
			},
			{ rootMargin: "120px" },
		);
		observer.observe(root);

		const sizer = new ResizeObserver(() => resize());
		sizer.observe(root);

		/*
		 * The pointer moves the vanishing point.
		 *
		 * Listened for on the window rather than on the root, for two
		 * reasons: in page mode the root is `pointer-events: none` and would
		 * never see a move, and in section mode we want to know when the
		 * pointer has left as well as where it is. The root's own box is
		 * what decides whether it counts.
		 */
		if (tracking) {
			window.addEventListener(
				"pointermove",
				(event) => {
					/* A touch drag is a scroll, not a look. Pen and mouse only. */
					if (event.pointerType === "touch") return;
					if (reduced.matches) return;
					const box = root.getBoundingClientRect();
					if (box.width < 1 || box.height < 1) return;
					const inside =
						event.clientX >= box.left &&
						event.clientX <= box.right &&
						event.clientY >= box.top &&
						event.clientY <= box.bottom;
					/*
					 * Outside the box, the field holds the angle it was last
					 * given rather than springing back to rest. Snapping home
					 * the moment the pointer leaves reads as the field
					 * rejecting you; holding reads as it having been aimed.
					 */
					if (!inside) return;
					const px = (event.clientX - box.left) / box.width;
					const py = (event.clientY - box.top) / box.height;
					/*
					 * Offset from the middle, not from `focusX`/`focusY` —
					 * this is a heading rather than a destination. Centre is
					 * straight ahead; an edge is a full turn.
					 *
					 * Negated, because steering is the camera's motion and
					 * the field is what you see of it: turn toward the
					 * cursor and the world sweeps the other way, which is
					 * what puts the point everything streams out of under
					 * the pointer. Taking the sign the obvious way round
					 * sent the stars toward the cursor instead, with the
					 * vanishing point fleeing to the far corner.
					 */
					aimX = -(px - 0.5) * 2 * STEER_THROW * pointerStrength;
					aimY = -(py - 0.5) * 2 * STEER_THROW * pointerStrength;
				},
				{ signal, passive: true },
			);

		}

		reduced.addEventListener("change", run, { signal });
		window.addEventListener("pageshow", run, { signal });

		resize();
		run();

		function destroy() {
			stop();
			observer.disconnect();
			sizer.disconnect();
			if (ctx) {
				ctx.setTransform(1, 0, 0, 1, 0, 0);
				ctx.clearRect(0, 0, canvas!.width, canvas!.height);
			}
			/* Back to the served state: an unsized canvas with nothing on it. */
			canvas!.removeAttribute("width");
			canvas!.removeAttribute("height");
			stars = [];
			anchors = [];
			steerX = aimX = 0;
			steerY = aimY = 0;
			delete root.dataset.sfReady;
			controller.abort();
		}

		/*
		 * Astro replaces the document on a client-side navigation. Cancel the
		 * loop, drop both observers, 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-starfield]").forEach(initStarfield);

	/* 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>
	.starfield {
		box-sizing: border-box;
		background: var(--sf-bg);
	}

	.starfield--section {
		position: relative;
		min-block-size: var(--sf-min-height);
		/* An isolated stacking context, so the canvas sits under this
		   component's own content and not under the host's. */
		isolation: isolate;
	}

	/*
	 * The whole-page backdrop. Fixed to the viewport and behind everything,
	 * which is the one job a background has.
	 *
	 * `z-index: -1` rather than a positive index under the content: it puts
	 * the field behind the page's normal flow without the page having to
	 * know it is there or give its own content a stacking order. The cost is
	 * that a host which makes `body` a stacking context — a transform, a
	 * filter, `isolation` — will trap it. That is noted in the README rather
	 * than worked around, because the workaround is worse than the note.
	 */
	.starfield--page {
		position: fixed;
		inset: 0;
		z-index: -1;
		pointer-events: none;
	}

	.sf-canvas {
		position: absolute;
		inset: 0;
		z-index: 0;
		inline-size: 100%;
		block-size: 100%;
		display: block;
		/* The field is scenery. It never takes a click meant for the content
		   above it, and it is out of the accessibility tree besides. */
		pointer-events: none;
	}

	.sf-content {
		position: relative;
		z-index: 1;
	}

	/*
	 * Nothing here needs a reduced-motion rule. The animation is a canvas
	 * loop rather than a transition, so the preference is read in script and
	 * the field is drawn once and left standing. A CSS rule could only hide
	 * it, which is the one outcome worth avoiding.
	 */

	@media (forced-colors: active) {
		/* The stars are painted pixels and forced colours cannot reach them,
		   so the backdrop steps aside rather than leaving grey dots on a
		   system background. */
		.sf-canvas {
			display: none;
		}

		.starfield {
			background: Canvas;
		}
	}
</style>