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.