---
name: starfield-background
description: Use when you want "Cinematic, cosmic, futuristic" - Creates a moving field of stars or space particles.
---

# Starfield Background

> **Category:** Background  -  **Personality:** Cinematic, cosmic, futuristic
>
> **Best use:** Space, gaming, AI, storytelling
>
> **Ratings:** Professional 3/5 - Casual 4/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 3/5 - Performance cost 3/5

## What it is

A canvas "warp speed" starfield: hundreds of stars sit in 3D space and stream **outward from a central vanishing point** as they fly toward the viewer. Each star has an `x, y, z`; you project it to the screen with a perspective divide (`screenX = x / z`), shrink `z` every frame so the star rushes past the camera, and recycle it to the far plane when it exits. Drawing a short line from the star's previous position to its new one turns the dots into hyperspace streaks — and the streaks lengthen automatically as the speed ramps. It's the quintessential sci-fi / cosmic hero backdrop.

## Dependencies / CDN

**None - vanilla JS + 2D canvas.** No WebGL, no libraries. The demo loads display/mono fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=JetBrains+Mono:wght@400;500&family=Hanken+Grotesk:wght@400;500&display=swap" rel="stylesheet">
```

## HTML

The whole effect is one `<canvas>` behind your content. The demo layers a nebula gradient (CSS), a vignette, a scrim, and a telemetry HUD on top — all of that is dressing; only the canvas matters.

```html
<section class="stage">
  <canvas class="starfield" aria-hidden="true"></canvas>
  <!-- your hero content sits above the canvas -->
  <div class="content">
    <h1>Cross the dark between the stars.</h1>
    <button id="warp" aria-pressed="false">Engage warp</button>
  </div>
</section>
```

## CSS

The stage is a contained, rounded panel; the canvas fills it; content stacks above. A radial-gradient nebula on the stage gives the transparent canvas something cosmic to sit on.

```css
.stage{
  position:relative; overflow:hidden; border-radius:26px; isolation:isolate;
  min-height:clamp(540px,72vh,684px);
  background:                                  /* nebula behind the transparent canvas */
    radial-gradient(120% 86% at 50% 124%, rgba(124,77,255,.20), transparent 58%),
    radial-gradient(86% 70% at 16% 8%,   rgba(56,189,248,.14), transparent 56%),
    #03040b;
}
.starfield{position:absolute; inset:0; width:100%; height:100%; z-index:0; display:block}
.content{position:relative; z-index:2; padding:34px; color:#e8f1ff}
/* optional depth: a vignette + side scrim between canvas (z0) and content (z2)
   keep them pointer-events:none so they never block clicks */
```

## JavaScript

The full warp engine: DPR-aware sizing, density by area, eased speed ramp, pointer-steered vanishing point, off-screen / hidden pause, and a static scatter under `prefers-reduced-motion`.

```js
(function(){
  var stage=document.querySelector('.stage'), canvas=document.querySelector('.starfield');
  var ctx=canvas.getContext('2d'); if(!ctx) return;        // no canvas -> CSS nebula remains
  var mq=matchMedia('(prefers-reduced-motion: reduce)');

  var IDLE=1.15, WARP=30, EASE=0.045, SPREAD=1.35;
  var PAL=['255,255,255','255,255,255','255,255,255','174,233,255','158,197,255','205,180,255','255,217,168'];
  var dpr=1,W=0,H=0,hW=0,hH=0,FOCAL=0,DEPTH=0;
  var stars=[], speed=0, target=IDLE, warpOn=false;
  var cx=0,cy=0,tcx=0,tcy=0, raf=null, running=false, inView=true, vis=true;

  function rand(a,b){return a+Math.random()*(b-a);}
  function spawn(s,fresh){                                  // place a star in the 3D box
    s.x=rand(-hW*SPREAD,hW*SPREAD); s.y=rand(-hH*SPREAD,hH*SPREAD);
    s.z=fresh?rand(1,DEPTH):DEPTH;  s.c=PAL[(Math.random()*PAL.length)|0];
  }
  function seed(){                                          // density scales with area
    var n=Math.round(Math.min(820,Math.max(190,(W*H)/2200))); stars.length=0;
    for(var i=0;i<n;i++){var s={};spawn(s,true);stars.push(s);}
  }
  function resize(){
    var r=stage.getBoundingClientRect(); W=Math.max(1,r.width); H=Math.max(1,r.height);
    dpr=Math.min(2,window.devicePixelRatio||1);             // DPR-aware, capped at 2
    canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
    ctx.setTransform(dpr,0,0,dpr,0,0);                       // draw in CSS pixels
    hW=W/2; hH=H/2; FOCAL=W*0.62; DEPTH=Math.max(W,640)*1.5;
    cx=tcx=hW; cy=tcy=hH; seed(); if(mq.matches) drawStatic();
  }
  function drawStatic(){                                     // reduced-motion: motionless scatter
    ctx.clearRect(0,0,W,H); ctx.globalCompositeOperation='lighter';
    for(var i=0;i<stars.length;i++){var s=stars[i],
      sx=hW+s.x/s.z*FOCAL, sy=hH+s.y/s.z*FOCAL; if(sx<0||sx>W||sy<0||sy>H)continue;
      var k=1-s.z/DEPTH; ctx.fillStyle='rgba('+s.c+','+Math.min(1,k*1.3+0.06).toFixed(3)+')';
      ctx.beginPath(); ctx.arc(sx,sy,Math.max(0.4,k*2.1+0.4),0,6.2832); ctx.fill();}
    ctx.globalCompositeOperation='source-over';
  }
  function step(){
    speed+=(target-speed)*EASE;                             // ease toward idle / warp
    cx+=(tcx-cx)*0.06; cy+=(tcy-cy)*0.06;                   // glide the vanishing point
    ctx.clearRect(0,0,W,H); ctx.globalCompositeOperation='lighter'; ctx.lineCap='round';
    for(var i=0;i<stars.length;i++){
      var s=stars[i], oz=s.z; s.z-=speed;                   // fly toward the camera
      if(s.z<1){ spawn(s,false); continue; }                // recycle at the far plane
      var sx=cx+s.x/s.z*FOCAL, sy=cy+s.y/s.z*FOCAL;         // perspective project
      if(sx<-40||sx>W+40||sy<-40||sy>H+40) continue;
      var ox=cx+s.x/oz*FOCAL, oy=cy+s.y/oz*FOCAL;           // previous position -> streak
      var k=1-s.z/DEPTH;
      ctx.strokeStyle='rgba('+s.c+','+Math.min(1,k*1.25+0.05).toFixed(3)+')';
      ctx.lineWidth=Math.max(0.5,k*2.6+0.5);
      ctx.beginPath(); ctx.moveTo(ox,oy); ctx.lineTo(sx,sy); ctx.stroke();
    }
    ctx.globalCompositeOperation='source-over';
  }
  function frame(){ raf=requestAnimationFrame(frame); step(); }
  function start(){ if(running||mq.matches)return; running=true; frame(); }
  function stop(){ running=false; if(raf)cancelAnimationFrame(raf); raf=null; }
  function sync(){ if(mq.matches){stop();drawStatic();return;} (inView&&vis)?start():stop(); }

  // interaction: toggle warp; steer the vanishing point with the pointer
  var btn=document.getElementById('warp');
  if(btn) btn.addEventListener('click',function(){ if(mq.matches)return;
    warpOn=!warpOn; target=warpOn?WARP:IDLE; btn.setAttribute('aria-pressed',warpOn); });
  if(!mq.matches){
    stage.addEventListener('pointermove',function(e){ var r=stage.getBoundingClientRect();
      tcx=hW+((e.clientX-r.left)/r.width-0.5)*hW*0.22; tcy=hH+((e.clientY-r.top)/r.height-0.5)*hH*0.22; });
    stage.addEventListener('pointerleave',function(){ tcx=hW; tcy=hH; });
  }
  // pause when off-screen or tab hidden
  new IntersectionObserver(function(en){ inView=en[0].isIntersecting; sync(); },{threshold:0.05}).observe(stage);
  document.addEventListener('visibilitychange',function(){ vis=!document.hidden; sync(); });
  var rt; new ResizeObserver(function(){ clearTimeout(rt); rt=setTimeout(resize,150); }).observe(stage);
  mq.addEventListener&&mq.addEventListener('change',function(){ resize(); sync(); });

  resize(); mq.matches?drawStatic():sync();
})();
```

## How it works

- **Perspective projection.** Each star is `(x, y, z)`. The screen position is `cx + x/z * FOCAL` (and the same for `y`). Dividing by `z` is the entire 3D illusion: small `z` (near) throws the star far from centre; large `z` (far) keeps it near the vanishing point.
- **Flying forward.** Every frame `z -= speed`. As `z` shrinks the projected point accelerates outward — the classic streaming-stars motion. When `z < 1` the star has passed the camera, so it respawns at the far plane (`z = DEPTH`) with fresh `x, y`.
- **Warp streaks.** Cache the old `z` (`oz`), project both the old and new positions, and stroke a line between them. At idle the gap is sub-pixel (a dot); when `speed` ramps, the gap stretches into a comet trail — the warp lines come for free.
- **Speed ramp.** `speed` eases toward a `target` (`IDLE` or `WARP`) by `EASE` each frame, so engaging/disengaging accelerates and decelerates smoothly instead of snapping.
- **`globalCompositeOperation = 'lighter'`** adds overlapping star colours, so the dense core glows white-hot like a real warp tunnel.
- **Depth shading.** `k = 1 - z/DEPTH` (0 far → 1 near) drives both line width and alpha, so nearer stars are brighter and fatter.

## Customization

- **Speed / drama:** raise `WARP` for a more violent jump; raise `IDLE` for faster ambient drift; lower `EASE` for a longer, more cinematic ramp.
- **Density:** change the `(W*H)/2200` divisor — smaller = more stars. The `min/max` clamp keeps tiny and huge canvases sane.
- **Field of view:** `FOCAL` (try `W*0.45`–`W*0.8`) controls how aggressively stars splay outward; `DEPTH` sets how far they travel before recycling.
- **Colour:** edit `PAL` (the repeated `255,255,255` entries weight the field toward white). Tint streaks blue for a cold jump, amber for a warm one.
- **Auto-warp:** skip the button and just set `target = WARP` for a permanently-streaming hyperspace background.

## Accessibility & performance

- **`prefers-reduced-motion`:** checked in JS via `matchMedia` *before* the loop ever starts. When set, `drawStatic()` paints one motionless, depth-varied star scatter and `requestAnimationFrame` is never scheduled. A `change` listener re-renders if the user flips the setting.
- **DPR-aware:** the backing store is `cssSize * devicePixelRatio` (capped at 2) with `ctx.setTransform(dpr,…)`, so stars are crisp on Retina without exploding fill-rate on 3x phones.
- **Pauses when idle:** an `IntersectionObserver` stops the loop when the stage scrolls out of view, and `visibilitychange` stops it when the tab is hidden — no battery burn off-screen.
- **Cheap:** a few hundred stroked lines per frame, no per-frame allocations (stars are mutated in place, never re-created), and `ResizeObserver` is debounced.
- Keep real text in the DOM above the canvas (`aria-hidden="true"` on the canvas); add a vignette/scrim so copy keeps contrast over the bright centre.

## Gotchas

- **Bitwise vs. comparison precedence:** if you throttle UI with `(frame++ & 3) === 0`, keep the parentheses — `&` binds *looser* than `===` in JS, so `frame++ & 3 === 0` silently evaluates wrong.
- **Guard `z` against zero.** Recycle on `z < 1` (not `z <= 0`); dividing by a near-zero `z` produces `Infinity` and NaN coordinates that throw off `canvas`.
- **Don't draw the recycle streak.** When a star respawns, `continue` *without* stroking — otherwise you get a full-screen line from the far plane to the old near position.
- **Transparent canvas needs a backdrop.** With `clearRect` + `'lighter'` the canvas is transparent between stars; put the nebula gradient on the parent (or fill the canvas black) or the effect floats on nothing.
- **`'lighter'` blows out to white fast.** Lower star alphas if the centre clips to a flat white blob.
- **Re-seed on resize**, and reset the transform with `setTransform` (not repeated `scale`) so DPR scaling doesn't compound on every resize.
