@fxshelf/starfield
Version:
Canvas 2D starfield tunnel for React and Vue — perspective fly-through, glitter flashes, additive trails. Zero animation-lib deps. Copy-as-source friendly.
342 lines (338 loc) • 10.3 kB
JavaScript
"use client";
import { useRef, useState, useEffect } from 'react';
import { jsxs, jsx } from 'react/jsx-runtime';
// src/react/Starfield.tsx
// src/core/types.ts
var DEFAULTS = {
particleCount: 263,
color1: "#ffffff",
color2: "#ffffff",
color3: "#ffffff",
speed: 1,
density: 48,
starSize: 8,
focalDepth: 13,
turbulence: 0,
brightness: 73,
glitterIntensity: 4,
trailAmount: 26,
reverse: false
};
// src/core/createStarfield.ts
function parseColor(input) {
if (!input) return [255, 255, 255, 1];
const s = input.trim();
if (s.charAt(0) === "#") {
let hex = s.slice(1);
if (hex.length === 3) {
hex = hex.split("").map((c) => c + c).join("");
}
const num = parseInt(hex, 16);
return [num >> 16 & 255, num >> 8 & 255, num & 255, 1];
}
const m = s.match(/rgba?\(([^)]+)\)/i);
if (m?.[1]) {
const parts = m[1].split(",").map((p) => parseFloat(p.trim()));
return [parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] == null ? 1 : parts[3]];
}
return [255, 255, 255, 1];
}
function toRuntime(options) {
return {
reverse: options.reverse,
density: options.density,
stepZ: options.speed * 8e-4,
focalDepth: options.focalDepth / 100,
starScale: options.starSize * 0.15,
turbulence: options.turbulence * 0.2,
glitter: options.glitterIntensity * 0.1,
brightness: Math.min(1, options.brightness / 100),
trail: options.trailAmount / 100
};
}
function createStarfield(canvas, options = {}) {
let config = { ...DEFAULTS, ...options };
const maybeCtx = canvas.getContext("2d");
if (!maybeCtx) {
return {
setOptions() {
},
resize() {
},
destroy() {
}
};
}
const ctx = maybeCtx;
const container = canvas.parentElement;
let w = 0;
let h = 0;
let dpr = 1;
let stars = [];
let elapsed = 0;
let lastT = performance.now();
let raf = null;
let destroyed = false;
const colorCache = {
color1: "",
color2: "",
color3: "",
parsed1: [255, 255, 255, 1],
parsed2: [255, 255, 255, 1],
parsed3: [255, 255, 255, 1]
};
function getCachedColors() {
if (config.color1 !== colorCache.color1) {
colorCache.color1 = config.color1;
colorCache.parsed1 = parseColor(config.color1);
}
if (config.color2 !== colorCache.color2) {
colorCache.color2 = config.color2;
colorCache.parsed2 = parseColor(config.color2);
}
if (config.color3 !== colorCache.color3) {
colorCache.color3 = config.color3;
colorCache.parsed3 = parseColor(config.color3);
}
return colorCache;
}
function makeStar() {
return {
x: 0,
y: 0,
z: 0,
px: NaN,
py: NaN,
seed: 0,
vmul: 1,
colorIdx: 0,
flashUntil: 0,
nextFlash: 0
};
}
function resetStar(s, initial = false) {
const c = toRuntime(config);
const angle = Math.random() * Math.PI * 2;
const radius = (0.2 + Math.random() * 0.8) * (c.density / 15);
s.x = Math.cos(angle) * radius;
s.y = Math.sin(angle) * radius;
if (c.reverse) {
s.z = initial ? c.focalDepth + Math.random() * (1 - c.focalDepth) : c.focalDepth;
} else {
s.z = initial ? Math.random() : 1;
}
s.px = NaN;
s.py = NaN;
s.seed = Math.random() * 1e3;
s.vmul = 0.6 + Math.random() * 0.8;
s.colorIdx = Math.floor(Math.random() * 3);
s.flashUntil = 0;
s.nextFlash = elapsed + 1 + Math.random() * 4 * (1 / Math.max(1e-4, c.glitter));
}
function syncCount() {
const count = Math.max(1, Math.floor(config.particleCount));
if (stars.length === count) return;
if (stars.length > count) {
stars.length = count;
return;
}
while (stars.length < count) {
const s = makeStar();
resetStar(s, true);
stars.push(s);
}
}
function resize() {
if (destroyed) return;
const nextDpr = Math.min(window.devicePixelRatio || 1, 2);
const rectW = container?.clientWidth || container?.getBoundingClientRect().width || canvas.clientWidth;
const rectH = container?.clientHeight || container?.getBoundingClientRect().height || canvas.clientHeight;
const nextW = Math.max(1, Math.floor(rectW) || 600);
const nextH = Math.max(1, Math.floor(rectH) || 400);
if (w === nextW && h === nextH && dpr === nextDpr) return;
w = nextW;
h = nextH;
dpr = nextDpr;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
canvas.style.width = w + "px";
canvas.style.height = h + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
}
function drawFrame(deltaSec) {
const c = toRuntime(config);
syncCount();
const colors = getCachedColors();
const palette = [colors.parsed1, colors.parsed2, colors.parsed3];
const rgbStrs = [
"rgb(" + palette[0][0] + ", " + palette[0][1] + ", " + palette[0][2] + ")",
"rgb(" + palette[1][0] + ", " + palette[1][1] + ", " + palette[1][2] + ")",
"rgb(" + palette[2][0] + ", " + palette[2][1] + ", " + palette[2][2] + ")"
];
const cx = w / 2;
const cy = h / 2;
const projScale = Math.min(w, h) * 0.9;
const dt = Math.max(1e-3, Math.min(0.1, deltaSec)) * 60;
const keep = Math.pow(Math.min(0.98, Math.max(0, c.trail)), dt);
const trailAlpha = Math.max(0.02, 1 - keep);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0, 0, 0, " + trailAlpha + ")";
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < stars.length; i++) {
const s = stars[i];
const vz = c.stepZ * s.vmul * dt;
if (c.reverse) {
s.z += vz;
if (s.z >= 1) {
resetStar(s);
continue;
}
} else {
s.z -= vz;
if (s.z <= c.focalDepth) {
resetStar(s);
continue;
}
}
let tx = s.x;
let ty = s.y;
if (c.turbulence > 0) {
const tVal = elapsed * 1.2 + s.seed;
const amp = c.turbulence * (1 - s.z) * 0.25;
tx += Math.sin(tVal + s.seed) * amp;
ty += Math.cos(tVal * 1.13 + s.seed * 0.7) * amp;
}
const persp = c.focalDepth / Math.max(s.z, 1e-4);
const sx = cx + tx * persp * projScale;
const sy = cy + ty * persp * projScale;
if (!c.reverse && (sx < -20 || sx > w + 20 || sy < -20 || sy > h + 20)) {
resetStar(s);
continue;
}
let flashMult = 1;
if (c.glitter > 0) {
if (elapsed >= s.nextFlash && s.flashUntil < elapsed) {
s.flashUntil = elapsed + 0.04 + Math.random() * 0.07;
s.nextFlash = elapsed + 1 + Math.random() * 4 * (1 / Math.max(1e-4, c.glitter));
}
if (elapsed <= s.flashUntil) {
flashMult = 1 + 2.5 * c.glitter;
}
}
const sizePersp = Math.min(2.5, c.focalDepth / Math.max(s.z, 1e-4) * 0.6);
const baseR = Math.max(0.25, c.starScale * (0.4 + sizePersp));
const maxR = 1 + c.starScale * 2.5;
const r = Math.min(baseR * flashMult, maxR);
const lifeT = c.reverse ? s.z : 1 - s.z;
const fadeIn = c.reverse ? Math.min(1, (s.z - c.focalDepth) / (1 - c.focalDepth) / 0.12) : 1;
const a = Math.min(1, c.reverse ? 0.85 - lifeT * 0.6 : lifeT * 0.9 + 0.05) * fadeIn * c.brightness * (flashMult > 1 ? 1 : 0.85);
const colStr = rgbStrs[s.colorIdx];
if (!Number.isNaN(s.px) && !Number.isNaN(s.py)) {
ctx.globalAlpha = a * 0.5;
ctx.strokeStyle = colStr;
ctx.lineWidth = Math.max(0.4, r * 0.4);
ctx.beginPath();
ctx.moveTo(s.px, s.py);
ctx.lineTo(sx, sy);
ctx.stroke();
}
ctx.globalAlpha = a;
ctx.fillStyle = colStr;
ctx.fillRect(sx - r, sy - r, r * 2, r * 2);
if (flashMult > 1) {
const rf = Math.min(r * 1.4, maxR * 1.4);
ctx.globalAlpha = a * 0.5;
ctx.fillRect(sx - rf, sy - rf, rf * 2, rf * 2);
}
s.px = sx;
s.py = sy;
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
elapsed += Math.min(0.1, Math.max(0, deltaSec));
}
function loop(t) {
if (destroyed) return;
const deltaSec = (t - lastT) / 1e3;
lastT = t;
drawFrame(deltaSec);
raf = requestAnimationFrame(loop);
}
syncCount();
resize();
raf = requestAnimationFrame(loop);
let resizeObserver = null;
if (container && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(container);
}
return {
setOptions(partial) {
config = { ...config, ...partial };
syncCount();
},
resize,
destroy() {
destroyed = true;
if (raf != null) cancelAnimationFrame(raf);
raf = null;
resizeObserver?.disconnect();
resizeObserver = null;
stars = [];
}
};
}
function Starfield({
children,
className,
style,
ariaLabel = "Starfield background",
...options
}) {
const canvasRef = useRef(null);
const instanceRef = useRef(null);
const [initialOptions] = useState(options);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
instanceRef.current = createStarfield(canvas, initialOptions);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, [initialOptions]);
useEffect(() => {
instanceRef.current?.setOptions(options);
});
return /* @__PURE__ */ jsxs(
"div",
{
className,
style: { position: "relative", overflow: "hidden", ...style },
children: [
/* @__PURE__ */ jsx(
"canvas",
{
ref: canvasRef,
"aria-label": ariaLabel,
style: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
display: "block",
pointerEvents: "none"
}
}
),
children != null ? /* @__PURE__ */ jsx("div", { style: { position: "relative", zIndex: 1, height: "100%" }, children }) : null
]
}
);
}
export { DEFAULTS, Starfield, createStarfield };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map