Web InnoventixFreeCode

Starfield Background

Original · free

canvas starfield background

byWeb InnoventixReact + Tailwind
bgxstarfieldbackgrounds
Open

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
bgx-starfield.tsx
"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&nbsp;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 &lt;canvas&gt; — 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

motion

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 quote

Similar components

Browse all →