backgrounds
Starfield
A drifting starfield background layer with three depth layers at different speeds for a parallax effect. Token-colored dots (--foreground / --primary at varied alpha) twinkle via an opacity pulse and slowly drift in random directions. Far stars are tiny, faint, and slow; near stars are larger, brighter, and faster — a depth illusion with no Three.js or canvas. Geometry is randomised post-mount for hydration safety; an IntersectionObserver pauses the layer offscreen. Decorative (role=presentation, aria-hidden, pointer-events:none). Place sibling content at z-10.
starfield
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/starfieldUsage
Usagetsx
<Starfield />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| starCount | number | 180 | Total stars across all active layers. Default 180. |
| speed | number | 1 | Speed multiplier for drift and twinkle. 1 = default; >1 = faster. |
| layers | number | 3 | Depth layers (1–3). More layers = stronger parallax illusion. Default 3. |
Source
starfield.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./starfield.module.css";
// Layer config ordered far → near.
// fraction: share of total star count; driftBase: drift animation base duration (s);
// driftPx: max drift distance in pixels (parallax depth cue).
const LAYER_CONFIG = [
{ minSize: 0.6, maxSize: 1.1, minAlpha: 18, maxAlpha: 48, driftBase: 70, driftPx: 8, fraction: 0.50 },
{ minSize: 1.1, maxSize: 1.8, minAlpha: 38, maxAlpha: 74, driftBase: 40, driftPx: 14, fraction: 0.30 },
{ minSize: 1.7, maxSize: 3.2, minAlpha: 62, maxAlpha: 100, driftBase: 18, driftPx: 22, fraction: 0.20 },
] as const;
interface StarData {
x: number; // left in %
y: number; // top in %
size: string; // e.g. "1.2px"
alpha: string; // e.g. "54%" — fed into color-mix
twinkleDur: string; // e.g. "3.2s"
twinkleDelay: string; // negative = skip ahead, e.g. "-1.8s"
driftDur: string; // e.g. "62s"
driftX: string; // e.g. "6px"
driftY: string; // e.g. "-4px"
isPrimary: boolean; // true → --primary accent; false → --foreground base
}
export interface StarfieldProps extends React.HTMLAttributes<HTMLDivElement> {
/** Total stars across all active layers. Default 180. */
starCount?: number;
/** Speed multiplier for drift and twinkle. 1 = default; >1 = faster. */
speed?: number;
/** Depth layers (1–3). More layers = stronger parallax illusion. Default 3. */
layers?: number;
}
/**
* Drifting starfield background layer (client component).
*
* Three depth layers of token-colored dots (--foreground / --primary at varied
* alpha) twinkle via an opacity pulse and drift slowly in random directions.
* Far stars are tiny, faint, and slow; near stars are larger, brighter, and
* faster — creating parallax depth without Three.js or canvas.
*
* Geometry is randomised after mount (hydration-safe; React #418-clean).
* An IntersectionObserver pauses the layer when scrolled off-screen.
*
* Decorative: role="presentation" + aria-hidden; pointer-events:none.
* Place sibling content at z-10 above this z-0 layer.
* Reduced-motion: static star field (same dots, no twinkle or drift).
*/
export function Starfield({
starCount = 180,
speed = 1,
layers = 3,
className = "",
style,
"aria-hidden": ariaHidden = true,
...props
}: StarfieldProps) {
// Contract mount-gate + null-safe reduce check (avoids React #418 + flash).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
const rootRef = useRef<HTMLDivElement>(null);
const [stars, setStars] = useState<StarData[]>([]);
const [playing, setPlaying] = useState(true);
// Generate randomised star geometry post-mount (hydration-safe).
useEffect(() => {
if (!mounted) return;
const s = Math.max(0.1, speed); // guard divide-by-zero
const clampedLayers = Math.max(1, Math.min(3, layers));
const activeLayers = LAYER_CONFIG.slice(0, clampedLayers);
// Normalise fractions so starCount is always honoured regardless of layer count.
const totalFraction = activeLayers.reduce((sum, l) => sum + l.fraction, 0);
const next: StarData[] = [];
activeLayers.forEach((cfg) => {
const count = Math.round(starCount * (cfg.fraction / totalFraction));
for (let i = 0; i < count; i++) {
const isPrimary = Math.random() < 0.18; // ~18% warm accent stars (hero points)
const size = Math.random() * (cfg.maxSize - cfg.minSize) + cfg.minSize;
const alpha = Math.round(Math.random() * (cfg.maxAlpha - cfg.minAlpha) + cfg.minAlpha);
// Twinkle: 2–6 s per cycle, scaled by speed. Negative delay skips into cycle.
const twinkleDur = (Math.random() * 4 + 2) / s;
const twinkleDelay = -(Math.random() * twinkleDur); // stagger phase at t=0
// Drift: longer for far layers (slow) than near (fast), scaled by speed.
const driftDur = (Math.random() * cfg.driftBase * 0.5 + cfg.driftBase * 0.75) / s;
// Random drift direction per star, bounded to cfg.driftPx radius.
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * cfg.driftPx;
const driftX = Math.cos(angle) * dist;
const driftY = Math.sin(angle) * dist;
next.push({
x: Math.random() * 100,
y: Math.random() * 100,
size: `${size.toFixed(2)}px`,
alpha: `${alpha}%`,
twinkleDur: `${twinkleDur.toFixed(2)}s`,
twinkleDelay: `${twinkleDelay.toFixed(2)}s`,
driftDur: `${driftDur.toFixed(1)}s`,
driftX: `${driftX.toFixed(2)}px`,
driftY: `${driftY.toFixed(2)}px`,
isPrimary,
});
}
});
setStars(next);
}, [mounted, starCount, layers, speed]);
// Pause animations when scrolled out of view to save GPU.
useEffect(() => {
if (!animate) return;
const el = rootRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setPlaying(entry.isIntersecting),
{ rootMargin: "0px" },
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
return (
<div
ref={rootRef}
role="presentation"
aria-hidden={ariaHidden}
className={[styles.root, className].filter(Boolean).join(" ")}
style={style}
{...props}
>
{/* Render only after mount — SSR + first client paint sees nothing (hydration-safe). */}
{mounted &&
stars.map((star, i) => (
<span
key={i}
className={[
styles.star,
star.isPrimary ? styles.starPrimary : styles.starFg,
animate ? styles.starAnimated : "",
]
.filter(Boolean)
.join(" ")}
style={
{
left: `${star.x}%`,
top: `${star.y}%`,
"--star-size": star.size,
"--star-alpha": star.alpha,
"--star-twinkle-dur": star.twinkleDur,
"--star-twinkle-delay": star.twinkleDelay,
"--star-drift-dur": star.driftDur,
"--star-drift-x": star.driftX,
"--star-drift-y": star.driftY,
// IntersectionObserver pauses all animations when off-screen.
"--star-play": playing ? "running" : "paused",
} as React.CSSProperties
}
/>
))}
</div>
);
}
Dependencies
- motion
- @bottega/tokens