Starfield Background
Original · freecanvas starfield background
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/bgx-starfield.json"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useReducedMotion } from "motion/react";
type PaletteKey = "aurora" | "ember" | "frost" | "nebula";
interface PaletteDef {
key: PaletteKey;
label: string;
dark: string[];
light: string[];
}
interface Star {
nx: number;
ny: number;
nz: number;
px: number;
py: number;
seed: number;
fresh: boolean;
}
interface Settings {
playing: boolean;
warp: boolean;
parallax: boolean;
speed: number;
density: number;
palette: PaletteKey;
dark: boolean;
}
const TAU = Math.PI * 2;
const MIN_Z = 0.06;
const MIN_SIZE = 0.5;
const MAX_SIZE = 2.6;
const BASE_SPEED = 0.22;
const MAX_PARALLAX = 55;
const BG_DARK = "2,6,23";
const BG_LIGHT = "248,250,252";
const PALETTES: PaletteDef[] = [
{
key: "aurora",
label: "Aurora",
dark: ["255,255,255", "165,180,252", "196,181,253", "125,211,252"],
light: ["79,70,229", "124,58,237", "2,132,199", "51,65,85"],
},
{
key: "ember",
label: "Ember",
dark: ["253,230,138", "253,164,175", "255,255,255", "251,191,36"],
light: ["217,119,6", "225,29,72", "180,83,9", "190,18,60"],
},
{
key: "frost",
label: "Frost",
dark: ["186,230,253", "255,255,255", "203,213,225", "125,211,252"],
light: ["2,132,199", "71,85,105", "3,105,161", "51,65,85"],
},
{
key: "nebula",
label: "Nebula",
dark: ["196,181,253", "110,231,183", "253,164,175", "255,255,255"],
light: ["124,58,237", "5,150,105", "225,29,72", "109,40,217"],
},
];
function detectDark(): boolean {
if (typeof document !== "undefined") {
const root = document.documentElement;
if (root.classList.contains("dark")) return true;
if (root.classList.contains("light")) return false;
}
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
return true;
}
export default function BgxStarfield() {
const prefersReduced = useReducedMotion();
const reduce = prefersReduced === true;
const [playing, setPlaying] = useState<boolean>(true);
const [warp, setWarp] = useState<boolean>(false);
const [parallax, setParallax] = useState<boolean>(true);
const [speed, setSpeed] = useState<number>(1);
const [density, setDensity] = useState<number>(320);
const [palette, setPalette] = useState<PaletteKey>("aurora");
const [dark, setDark] = useState<boolean>(false);
const sectionRef = useRef<HTMLElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const starsRef = useRef<Star[]>([]);
const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
const pointerRef = useRef<{ x: number; y: number; tx: number; ty: number }>({
x: 0,
y: 0,
tx: 0,
ty: 0,
});
const settingsRef = useRef<Settings>({
playing: true,
warp: false,
parallax: true,
speed: 1,
density: 320,
palette: "aurora",
dark: false,
});
const frameRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const ensureStars = useCallback((target: number) => {
const arr = starsRef.current;
if (arr.length < target) {
for (let i = arr.length; i < target; i++) {
arr.push({
nx: Math.random() * 2 - 1,
ny: Math.random() * 2 - 1,
nz: Math.max(Math.random(), MIN_Z),
px: 0,
py: 0,
seed: (Math.random() * 997) | 0,
fresh: true,
});
}
} else if (arr.length > target) {
arr.length = target;
}
}, []);
const render = useCallback(
(advance: boolean, step: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { w, h } = sizeRef.current;
if (w < 2 || h < 2) return;
const cfg = settingsRef.current;
ensureStars(cfg.density);
const stars = starsRef.current;
const bg = cfg.dark ? BG_DARK : BG_LIGHT;
const pal = PALETTES.find((p) => p.key === cfg.palette) ?? PALETTES[0];
const colors = cfg.dark ? pal.dark : pal.light;
if (cfg.warp && advance) {
ctx.fillStyle = `rgba(${bg},0.26)`;
} else {
ctx.fillStyle = `rgb(${bg})`;
}
ctx.fillRect(0, 0, w, h);
const cx = w / 2 + pointerRef.current.x;
const cy = h / 2 + pointerRef.current.y;
const halfW = w / 2;
const halfH = h / 2;
ctx.lineCap = "round";
for (let i = 0; i < stars.length; i++) {
const s = stars[i];
let justReset = s.fresh;
s.fresh = false;
if (advance) {
s.nz -= step;
if (s.nz <= MIN_Z) {
s.nz = 1;
s.nx = Math.random() * 2 - 1;
s.ny = Math.random() * 2 - 1;
s.seed = (Math.random() * 997) | 0;
justReset = true;
}
}
const k = 1 / s.nz;
const sx = cx + s.nx * halfW * k;
const sy = cy + s.ny * halfH * k;
const depth = 1 - s.nz;
const size = MIN_SIZE + depth * depth * MAX_SIZE;
const rawAlpha = depth * 1.15 + 0.18;
const alpha = rawAlpha > 1 ? 1 : rawAlpha;
const color = colors[s.seed % colors.length];
const onScreen = sx > -60 && sx < w + 60 && sy > -60 && sy < h + 60;
if (onScreen) {
if (cfg.warp && advance && !justReset) {
ctx.strokeStyle = `rgba(${color},${alpha})`;
ctx.lineWidth = size;
ctx.beginPath();
ctx.moveTo(s.px, s.py);
ctx.lineTo(sx, sy);
ctx.stroke();
} else {
ctx.fillStyle = `rgba(${color},${alpha})`;
ctx.beginPath();
ctx.arc(sx, sy, size, 0, TAU);
ctx.fill();
}
}
s.px = sx;
s.py = sy;
}
},
[ensureStars],
);
const startLoop = useCallback(() => {
const tick = (now: number) => {
const last = lastTimeRef.current || now;
let dt = (now - last) / 1000;
lastTimeRef.current = now;
if (dt > 0.05) dt = 0.05;
const p = pointerRef.current;
p.x += (p.tx - p.x) * 0.06;
p.y += (p.ty - p.y) * 0.06;
const stepValue = settingsRef.current.speed * BASE_SPEED * dt;
render(true, stepValue);
frameRef.current = requestAnimationFrame(tick);
};
lastTimeRef.current = 0;
frameRef.current = requestAnimationFrame(tick);
}, [render]);
useEffect(() => {
const resize = () => {
const canvas = canvasRef.current;
const section = sectionRef.current;
if (!canvas || !section) return;
const rect = section.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(rect.width));
const h = Math.max(1, Math.floor(rect.height));
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext("2d");
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sizeRef.current = { w, h };
render(false, 0);
};
resize();
const observer = new ResizeObserver(resize);
if (sectionRef.current) observer.observe(sectionRef.current);
window.addEventListener("resize", resize);
return () => {
observer.disconnect();
window.removeEventListener("resize", resize);
};
}, [render]);
useEffect(() => {
const update = () => setDark(detectDark());
update();
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", update);
const observer = new MutationObserver(update);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => {
mq.removeEventListener("change", update);
observer.disconnect();
};
}, []);
useEffect(() => {
settingsRef.current = { playing, warp, parallax, speed, density, palette, dark };
if (!parallax) {
const p = pointerRef.current;
p.x = 0;
p.y = 0;
p.tx = 0;
p.ty = 0;
}
if (!(playing && !reduce)) {
render(false, 0);
}
}, [playing, warp, parallax, speed, density, palette, dark, reduce, render]);
useEffect(() => {
if (playing && !reduce) {
startLoop();
return () => {
cancelAnimationFrame(frameRef.current);
};
}
render(false, 0);
return undefined;
}, [playing, reduce, startLoop, render]);
const handlePointerMove = (e: ReactPointerEvent<HTMLElement>) => {
if (!settingsRef.current.parallax) return;
const rect = e.currentTarget.getBoundingClientRect();
const nx = (e.clientX - rect.left) / rect.width - 0.5;
const ny = (e.clientY - rect.top) / rect.height - 0.5;
pointerRef.current.tx = nx * MAX_PARALLAX;
pointerRef.current.ty = ny * MAX_PARALLAX;
};
const handlePointerLeave = () => {
pointerRef.current.tx = 0;
pointerRef.current.ty = 0;
};
const handleSpeed = (e: ChangeEvent<HTMLInputElement>) => {
setSpeed(parseFloat(e.target.value));
};
const handleDensity = (e: ChangeEvent<HTMLInputElement>) => {
setDensity(parseInt(e.target.value, 10));
};
const resetDefaults = () => {
setPlaying(true);
setWarp(false);
setParallax(true);
setSpeed(1);
setDensity(320);
setPalette("aurora");
};
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-500 dark:focus-visible:ring-offset-slate-900";
return (
<section
ref={sectionRef}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
aria-label="Interactive canvas starfield background"
className="relative isolate w-full overflow-hidden bg-slate-50 dark:bg-slate-950"
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="absolute inset-0 h-full w-full"
/>
<div className="relative z-10 mx-auto flex min-h-[42rem] max-w-6xl flex-col items-center justify-center px-6 py-24 text-center sm:py-28">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/50 px-3 py-1 text-xs font-medium uppercase tracking-[0.14em] text-slate-600 backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/40 dark:text-slate-300">
<span
className="bgxstar-pulse h-1.5 w-1.5 rounded-full bg-emerald-500"
aria-hidden="true"
/>
Canvas background · zero dependencies
</span>
<h2 className="mt-6 text-4xl font-semibold tracking-tight text-slate-900 sm:text-6xl dark:text-white">
Deep Field
</h2>
<p className="mt-5 max-w-xl text-base leading-relaxed text-slate-600 sm:text-lg dark:text-slate-300">
A 3D starfield rendered on a single <canvas> — no images, no
WebGL, no libraries. It scales to any viewport, honors reduced-motion,
and every knob below is live.
</p>
<div className="mx-auto mt-10 w-full max-w-3xl rounded-2xl border border-slate-200/80 bg-white/70 p-5 text-left shadow-xl shadow-slate-900/5 backdrop-blur-md dark:border-slate-800/80 dark:bg-slate-900/60 dark:shadow-black/40 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setPlaying((v) => !v)}
disabled={reduce}
aria-label={
playing ? "Pause starfield animation" : "Play starfield animation"
}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full bg-slate-900 text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 ${focusRing}`}
>
{playing ? (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="currentColor"
aria-hidden="true"
>
<path d="M7 5h3v14H7zM14 5h3v14h-3z" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
className="ml-0.5 h-4 w-4"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
type="button"
onClick={resetDefaults}
aria-label="Reset controls to defaults"
className={`inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-300 text-slate-600 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:text-white ${focusRing}`}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v4h4" />
</svg>
</button>
</div>
<p
role="status"
aria-live="polite"
className="text-sm text-slate-500 dark:text-slate-400"
>
<span className="tabular-nums font-semibold text-slate-700 dark:text-slate-200">
{density}
</span>{" "}
stars
<span
className="mx-1.5 text-slate-300 dark:text-slate-600"
aria-hidden="true"
>
·
</span>
{reduce ? "motion reduced" : playing ? "drifting" : "paused"}
</p>
</div>
{reduce && (
<p className="mt-4 rounded-lg border border-amber-300/70 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
Your system requests reduced motion, so the field is held still.
Palette and density still apply.
</p>
)}
<div className="mt-5 grid gap-5 sm:grid-cols-2">
<div>
<div className="mb-2 flex items-center justify-between">
<label
htmlFor="bgxstar-speed"
className="text-sm font-medium text-slate-700 dark:text-slate-200"
>
Warp speed
</label>
<span className="tabular-nums text-sm font-semibold text-indigo-600 dark:text-indigo-300">
{speed.toFixed(1)}×
</span>
</div>
<input
id="bgxstar-speed"
type="range"
min={0.2}
max={3}
step={0.1}
value={speed}
disabled={reduce}
onChange={handleSpeed}
aria-describedby="bgxstar-speed-hint"
className={`w-full cursor-pointer rounded-full accent-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 ${focusRing}`}
/>
<p id="bgxstar-speed-hint" className="sr-only">
Controls how fast the stars fly toward you.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label
htmlFor="bgxstar-density"
className="text-sm font-medium text-slate-700 dark:text-slate-200"
>
Star density
</label>
<span className="tabular-nums text-sm font-semibold text-indigo-600 dark:text-indigo-300">
{density}
</span>
</div>
<input
id="bgxstar-density"
type="range"
min={120}
max={640}
step={20}
value={density}
onChange={handleDensity}
aria-describedby="bgxstar-density-hint"
className={`w-full cursor-pointer rounded-full accent-indigo-500 ${focusRing}`}
/>
<p id="bgxstar-density-hint" className="sr-only">
Controls how many stars populate the field.
</p>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">
<button
type="button"
role="switch"
aria-checked={warp}
onClick={() => setWarp((v) => !v)}
disabled={reduce}
className={`inline-flex items-center gap-2.5 rounded-full disabled:cursor-not-allowed disabled:opacity-40 ${focusRing}`}
>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition-colors ${
warp ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
warp ? "translate-x-4" : "translate-x-0"
}`}
/>
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
Warp trails
</span>
</button>
<button
type="button"
role="switch"
aria-checked={parallax}
onClick={() => setParallax((v) => !v)}
className={`inline-flex items-center gap-2.5 rounded-full ${focusRing}`}
>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition-colors ${
parallax ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
parallax ? "translate-x-4" : "translate-x-0"
}`}
/>
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
Pointer parallax
</span>
</button>
</div>
<fieldset className="mt-5">
<legend className="text-sm font-medium text-slate-700 dark:text-slate-200">
Palette
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{PALETTES.map((p) => (
<label key={p.key} className="relative">
<input
type="radio"
name="bgxstar-palette"
value={p.key}
checked={palette === p.key}
onChange={() => setPalette(p.key)}
className="peer sr-only"
/>
<span className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white/60 px-3 py-1.5 text-sm text-slate-600 transition peer-checked:border-indigo-400 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:peer-checked:border-indigo-500 dark:peer-checked:bg-indigo-500/15 dark:peer-checked:text-indigo-200 dark:peer-focus-visible:ring-offset-slate-900">
<span className="flex -space-x-1" aria-hidden="true">
{p.dark.slice(0, 3).map((c, idx) => (
<span
key={idx}
className="h-3 w-3 rounded-full ring-1 ring-black/10 dark:ring-white/10"
style={{ backgroundColor: `rgb(${c})` }}
/>
))}
</span>
{p.label}
</span>
</label>
))}
</div>
</fieldset>
</div>
</div>
<style>{`
@keyframes bgxstar-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.65); }
}
.bgxstar-pulse { animation: bgxstar-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.bgxstar-pulse { animation: none !important; }
}
`}</style>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Aurora Blobs Background
OriginalAn animated hero section with soft, ever-drifting aurora gradient blobs floating behind staggered, blur-in content.

Retro Perspective Grid
OriginalA launch and waitlist section set over an infinite neon grid that scrolls into a glowing horizon with rising particles.

Conic Gradient Flow
OriginalA frosted hero backed by twin counter-rotating conic gradients, with flowing gradient text and an animated gradient-border stat card.

Spotlight Follow Background
OriginalA dot-grid section where a spring-eased spotlight follows the cursor and reveals a hidden lattice of coloured dots.

Dot Pattern Background
pattern.tsxA reusable animated background for React and Tailwind: dot pattern background.

Flickering Grid Background
grid.tsxA reusable animated background for React and Tailwind: flickering grid background.

Grid Pattern Background
pattern.tsxA reusable animated background for React and Tailwind: grid pattern background.

Retro Grid Background
grid.jsonA reusable animated background for React and Tailwind: retro grid background.

Aurora Background
Originalaurora gradient background

Mesh Gradient Background
Originalanimated mesh gradient background

Grid Background
Originalgrid line background with fade

Dots Background
Originaldotted grid background

