Starfield Background with Depth and Shooting Stars

A space background with real depth. Hundreds of stars sit at different distances and drift slowly toward the viewer, so near stars grow, brighten and move faster than far ones. Every few seconds a shooting star crosses with a fading tail, and the view leans gently toward the pointer. A lit planet edge curves along the bottom and the hero copy, two buttons and a launch summary sit above it. On phones the buttons and summary stack.

Code

import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";

// Tune the sky here.
const SKY = {
  density: 1500, // one star per this many square pixels (lower = more stars)
  maxStars: 900,
  flight: 0.05, // how fast you travel through the field (0 = fixed stars)
  parallax: 46, // how far the view shifts toward the pointer, in pixels
  meteorGap: [1.4, 3.6], // seconds between shooting stars, min and max
  tints: ["255, 255, 255", "255, 255, 255", "191, 219, 254", "254, 240, 199"],
};

const launch = [
  { label: "Vehicle", value: "Kestrel 4" },
  { label: "Orbit", value: "Sun-synchronous, 520 km" },
  { label: "Window opens", value: "14 March, 06:42 UTC" },
];

type Star = { x: number; y: number; z: number; tint: string; phase: number };
type Meteor = { x: number; y: number; vx: number; vy: number; age: number; life: number; len: number };

export default function StarfieldBackgroundWithDepthAndShootingStars() {
  const hostRef = useRef<HTMLElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!host || !canvas || !ctx) return;
    const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let w = 0;
    let h = 0;
    let stars: Star[] = [];
    let meteors: Meteor[] = [];
    let raf = 0;
    let running = false;
    let visible = true;
    let last = 0;
    let clock = 0;
    let nextMeteor = 0.15;
    let fired = 0;
    const view = { x: 0, y: 0, tx: 0, ty: 0 };

    let focal = 1;
    const place = (s: Star, z: number) => {
      // Spread far stars evenly over the section, whatever its shape.
      s.x = (Math.random() * 2 - 1) * ((w / 2) / focal) * 1.3;
      s.y = (Math.random() * 2 - 1) * ((h / 2) / focal) * 1.3;
      s.z = z;
    };
    const seed = () => {
      const count = Math.max(160, Math.min(SKY.maxStars, Math.round((w * h) / SKY.density)));
      stars = Array.from({ length: count }, () => {
        const s: Star = { x: 0, y: 0, z: 1, tint: SKY.tints[Math.floor(Math.random() * SKY.tints.length)], phase: Math.random() * Math.PI * 2 };
        place(s, 0.12 + Math.random() * 0.88);
        return s;
      });
    };
    const fire = () => {
      const fromLeft = Math.random() < 0.5;
      const angle = (16 + Math.random() * 20) * (Math.PI / 180);
      const speed = 620 + Math.random() * 380;
      meteors.push({
        x: fromLeft ? w * (0.05 + Math.random() * 0.45) : w * (0.5 + Math.random() * 0.45),
        y: h * (0.04 + Math.random() * 0.3),
        vx: Math.cos(angle) * speed * (fromLeft ? 1 : -1),
        vy: Math.sin(angle) * speed,
        age: 0,
        life: 0.9 + Math.random() * 0.5,
        len: 150 + Math.random() * 130,
      });
    };

    const draw = (dt: number) => {
      clock += dt;
      const ease = dt ? 1 - Math.exp(-dt * 3) : 1;
      view.x += (view.tx - view.x) * ease;
      view.y += (view.ty - view.y) * ease;
      ctx.clearRect(0, 0, w, h);

      const cx = w / 2;
      const cy = h * 0.46;
      for (const s of stars) {
        s.z -= SKY.flight * dt;
        if (s.z <= 0.1) place(s, 1);
        const near = 1 - s.z; // 0 = far away, 0.9 = right in front of you
        const px = cx + (s.x / s.z) * focal + view.x * near;
        const py = cy + (s.y / s.z) * focal + view.y * near;
        if (px < -4 || px > w + 4 || py < -4 || py > h + 4) {
          if (s.z < 0.6) place(s, 1);
          continue;
        }
        const twinkle = 0.72 + 0.28 * Math.sin(clock * (1.2 + near * 2.2) + s.phase);
        const fadeIn = Math.min(1, (1 - s.z) * 9); // new stars arrive from the distance, they do not pop
        const alpha = Math.min(1, 0.4 + near * 1.0) * twinkle * fadeIn;
        const size = 0.55 + near * near * 2.1;
        ctx.globalAlpha = alpha;
        ctx.fillStyle = `rgb(${s.tint})`;
        ctx.beginPath();
        ctx.arc(px, py, size, 0, Math.PI * 2);
        ctx.fill();
        if (size > 1.5) {
          // The nearest stars get a soft bloom.
          ctx.globalAlpha = alpha * 0.16;
          ctx.beginPath();
          ctx.arc(px, py, size * 3.6, 0, Math.PI * 2);
          ctx.fill();
        }
      }

      if (dt) {
        nextMeteor -= dt;
        if (nextMeteor <= 0) {
          fire();
          fired += 1;
          const [min, max] = SKY.meteorGap;
          nextMeteor = fired < 3 ? 1.25 : min + Math.random() * (max - min);
        }
      }
      ctx.lineCap = "round";
      meteors = meteors.filter((m) => m.age < m.life);
      for (const m of meteors) {
        m.age += dt;
        m.x += m.vx * dt;
        m.y += m.vy * dt;
        const t = m.age / m.life;
        const alpha = Math.sin(Math.min(1, t) * Math.PI); // fade in, burn, fade out
        const speed = Math.hypot(m.vx, m.vy);
        const tail = m.len * Math.min(1, t * 3);
        const tx = m.x - (m.vx / speed) * tail;
        const ty = m.y - (m.vy / speed) * tail;
        const trail = ctx.createLinearGradient(m.x, m.y, tx, ty);
        trail.addColorStop(0, `rgba(255, 255, 255, ${alpha})`);
        trail.addColorStop(0.25, `rgba(186, 220, 255, ${alpha * 0.55})`);
        trail.addColorStop(1, "rgba(147, 197, 253, 0)");
        ctx.globalAlpha = 1;
        ctx.strokeStyle = trail;
        ctx.lineWidth = 1.8;
        ctx.beginPath();
        ctx.moveTo(m.x, m.y);
        ctx.lineTo(tx, ty);
        ctx.stroke();
        const glow = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, 14);
        glow.addColorStop(0, `rgba(255, 255, 255, ${alpha})`);
        glow.addColorStop(0.25, `rgba(191, 219, 254, ${alpha * 0.5})`);
        glow.addColorStop(1, "rgba(147, 197, 253, 0)");
        ctx.fillStyle = glow;
        ctx.fillRect(m.x - 14, m.y - 14, 28, 28);
      }
      ctx.globalAlpha = 1;
    };

    const resize = () => {
      const rect = host.getBoundingClientRect();
      if (Math.abs(rect.width - w) < 1 && Math.abs(rect.height - h) < 1) return;
      w = rect.width;
      h = rect.height;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      focal = Math.hypot(w, h) * 0.4;
      seed();
      draw(0);
    };
    const frame = (now: number) => {
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      draw(dt);
      raf = requestAnimationFrame(frame);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };
    const sync = () => {
      if (still || !visible || document.hidden) return stop();
      if (running) return;
      running = true;
      last = performance.now();
      raf = requestAnimationFrame(frame);
    };
    const onMove = (e: PointerEvent) => {
      const rect = host.getBoundingClientRect();
      view.tx = -((e.clientX - rect.left) / rect.width - 0.5) * 2 * SKY.parallax;
      view.ty = -((e.clientY - rect.top) / rect.height - 0.5) * 2 * SKY.parallax;
    };
    const onLeave = () => {
      view.tx = 0;
      view.ty = 0;
    };

    const ro = new ResizeObserver(resize);
    ro.observe(host);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      sync();
    });
    io.observe(host);
    document.addEventListener("visibilitychange", sync);
    host.addEventListener("pointermove", onMove);
    host.addEventListener("pointerleave", onLeave);
    resize();
    sync();

    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", sync);
      host.removeEventListener("pointermove", onMove);
      host.removeEventListener("pointerleave", onLeave);
    };
  }, []);

  const rise = (delay: number) => ({
    initial: reduce ? false : { opacity: 0, y: 20, filter: "blur(8px)" },
    animate: { opacity: 1, y: 0, filter: "blur(0px)" },
    transition: { duration: 0.9, ease: [0.16, 1, 0.3, 1] as [number, number, number, number], delay },
  });

  return (
    <section ref={hostRef} className="relative isolate overflow-hidden bg-black text-white">
      {/* Two faint nebula clouds give the black some depth */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-20 bg-[radial-gradient(60%_50%_at_18%_22%,rgba(49,46,129,0.5),transparent_70%),radial-gradient(45%_40%_at_86%_30%,rgba(8,47,73,0.6),transparent_70%)]" />
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 h-full w-full" />
      {/* The planet: one huge circle whose lit edge shows along the bottom */}
      <motion.div
        aria-hidden="true"
        className="pointer-events-none absolute left-1/2 top-[calc(100%-120px)] -z-10 aspect-square rounded-full bg-black"
        style={{
          width: "max(1500px, 170%)",
          x: "-50%",
          boxShadow: "0 -1px 0 0 rgba(219,234,254,0.9), 0 -6px 24px 0 rgba(147,197,253,0.55), 0 -30px 120px 0 rgba(59,130,246,0.45), inset 0 26px 60px -10px rgba(96,165,250,0.4), inset 0 2px 6px 0 rgba(219,234,254,0.6)",
        }}
        initial={reduce ? false : { opacity: 0, y: 60 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
      />

      <div className="mx-auto flex min-h-[700px] max-w-4xl flex-col items-center px-6 pb-44 pt-24 text-center sm:pt-28">
        <motion.p {...rise(0.1)} className="font-mono text-xs uppercase tracking-[0.28em] text-blue-200">
          Mission 27 ยท Rideshare
        </motion.p>
        <motion.h1 {...rise(0.2)} className="mt-6 text-4xl font-medium leading-[1.04] tracking-tight sm:text-7xl">
          Your payload in orbit,
          <br className="hidden sm:block" /> <span className="bg-gradient-to-b from-white to-blue-300 bg-clip-text text-transparent">on the day we promised</span>
        </motion.h1>
        <motion.p {...rise(0.32)} className="mt-6 max-w-xl text-base leading-relaxed text-slate-300 sm:text-lg">
          Perihelion flies small satellites to low Earth orbit every six weeks. Book a slot, ship your hardware, and track every stage from fairing to deployment.
        </motion.p>
        <motion.div {...rise(0.44)} className="mt-9 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
          <a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-full bg-white px-7 text-sm font-semibold text-black transition hover:bg-blue-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300 focus-visible:ring-offset-2 focus-visible:ring-offset-black sm:w-auto">
            Reserve a launch slot
          </a>
          <a href="#" className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-full border border-white/25 px-7 text-sm font-semibold text-white transition hover:border-white/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-300 sm:w-auto">
            <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
              <path d="M6.5 4.6a1 1 0 0 1 1.5-.86l8 4.9a1 1 0 0 1 0 1.72l-8 4.9a1 1 0 0 1-1.5-.86V4.6Z" />
            </svg>
            Watch the last launch
          </a>
        </motion.div>
        <motion.dl {...rise(0.58)} className="mt-12 flex w-full max-w-2xl flex-col gap-4 border-t border-white/15 pt-6 text-left sm:flex-row sm:justify-between sm:gap-8">
          {launch.map((l) => (
            <div key={l.label}>
              <dt className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">{l.label}</dt>
              <dd className="mt-1.5 text-sm font-medium text-white">{l.value}</dd>
            </div>
          ))}
        </motion.dl>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/starfield-background-with-depth-and-shooting-stars.json

When to use it

How to customize it

Accessibility

Questions

How is the depth effect made?

Each star has an x and y position and a depth value z between 0.1 and 1. To draw it, x and y are divided by z and scaled, which is a simple perspective projection: as z shrinks the star moves away from the centre and speeds up. Size and brightness are also tied to z. Each frame z is reduced a little, and a star that passes the viewer is sent back to the far distance and fades in again.

How do the shooting stars work?

A timer adds a meteor with a random start point, angle and speed. Each frame it moves along its path and is drawn as a line with a gradient from white at the head to transparent at the tail, plus a small radial glow on the head. Its opacity follows a sine curve over its life, so it fades in, burns and fades out.

How many stars can it draw before it gets slow?

A few thousand small circles a frame is fine on most devices, but there is little visual gain past about 600. The count scales with the area of the section and is capped by `maxStars`.

Does it pause when it is not visible?

Yes. The loop stops when the section scrolls off screen or the tab is hidden, and every observer and listener is removed when the component unmounts.

Can I use only the stars, without the planet?

Yes. Delete the `motion.div` marked as the planet and reduce the bottom padding on the content wrapper. The canvas does not depend on it.

Related: Aurora Background with Drifting Colour Bands, Particle Network Background with Connecting Lines, Dot Matrix Background with Ripples and Pointer Spotlight.

More animated backgrounds or all free UI components. Start for free to have a full site built around it.

More in Backgrounds

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap