---
name: vortex-background
description: Use when you want "Experimental, intense, sci-fi" - Creates a swirling tunnel or spiral motion effect.
---

# Vortex Background

> **Category:** Background  -  **Personality:** Experimental, intense, sci-fi
>
> **Best use:** Gaming, event pages, experimental visuals
>
> **Ratings:** Professional 1/5 - Casual 3/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

A full-bleed canvas background where hundreds of particles **spiral inward toward a single vanishing point** — rotating faster and faster as they fall in, then respawning at the rim. Each particle draws a short additive streak, and the canvas is never fully cleared (only faded), so the streaks smear into glowing spiral arms that read as a swirling tunnel or black-hole. It is loud, kinetic and unmistakably sci-fi, so reach for it on game reveals, festival/event heroes and experimental landing pages — always behind a dark scrim so foreground text stays legible.

## Dependencies / CDN

None - vanilla JS + canvas 2D (no libraries). The demo loads two display 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=Familjen+Grotesk:wght@400;500;600&family=Oxanium:wght@500;600;700;800&display=swap" rel="stylesheet">
```

## HTML

A stage holds three stacked layers: the `<canvas>`, a **dark scrim** for legibility, and the content.

```html
<div class="vx-stage">
  <canvas id="vx-canvas" class="vx-canvas" aria-hidden="true"></canvas>
  <div class="vx-scrim" aria-hidden="true"></div>
  <div class="vx-content">
    <h2 class="vx-title">Cross the<br><em>Event Horizon</em></h2>
    <p class="vx-sub">Three nights of generative light, spatial sound and machine-made art.</p>
    <button class="vx-btn">Get passes</button>
  </div>
</div>
```

## CSS

The scrim is the load-bearing part: a horizontal (or vertical, on mobile) gradient that keeps one side dark so text reads over the bright vortex.

```css
.vx-stage{position:relative;display:flex;border-radius:26px;overflow:hidden;min-height:600px;isolation:isolate;
  background:radial-gradient(120% 130% at 66% 44%,#0b0c22,#06071a 44%,#030308 100%);color:#eaf1ff}
.vx-canvas{position:absolute;inset:0;z-index:0;display:block}
.vx-scrim{position:absolute;inset:0;z-index:1;pointer-events:none;
  background:linear-gradient(90deg,rgba(3,4,12,.94) 0%,rgba(3,4,12,.78) 26%,rgba(3,4,12,.28) 52%,rgba(3,4,12,0) 72%),
             radial-gradient(150% 120% at 50% 128%,rgba(3,4,12,.7),transparent 58%)}
.vx-content{position:relative;z-index:2;flex:1;display:flex;flex-direction:column;justify-content:center;
  width:100%;max-width:1080px;margin:0 auto;padding:24px 36px 30px}
.vx-title em{font-style:normal;background:linear-gradient(100deg,#5ff7ff,#b388ff 48%,#ff5cf0);
  -webkit-background-clip:text;background-clip:text;color:transparent;filter:drop-shadow(0 0 24px rgba(179,136,255,.45))}

/* On narrow screens, flip the scrim so the bottom (where the text sits) stays dark */
@media(max-width:760px){
  .vx-scrim{background:linear-gradient(0deg,rgba(3,4,12,.96) 0%,rgba(3,4,12,.74) 30%,rgba(3,4,12,.06) 64%,transparent 82%),
            linear-gradient(180deg,rgba(3,4,12,.66),transparent 20%)}
  .vx-content{justify-content:flex-end}
}
```

## JavaScript

The whole effect. DPR-aware sizing, particles that spiral in and respawn, additive streaks over a translucent fade, pause when off-screen/hidden, and a static spiral for reduced motion.

```js
(function(){
  var stage=document.querySelector('.vx-stage'), canvas=document.getElementById('vx-canvas');
  if(!stage||!canvas) return;
  var ctx=canvas.getContext('2d');
  var reduce=window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches;
  var dpr=1,W=0,H=0,cx=0,cy=0,maxR=1,minR=2,ps=[],COUNT=0,raf=0,running=false,onscreen=true,visible=true,last=0;
  function rand(a,b){ return a+Math.random()*(b-a); }

  function reset(p,edge){                              // edge=true -> respawn at the rim
    p.a=rand(0,Math.PI*2);
    p.r=edge?rand(maxR*0.62,maxR*1.06):rand(minR,maxR);
    p.spin=rand(0.65,1.45); p.w=rand(0.6,2.4); p.hue=188+rand(-16,16); p.fade=0;
  }
  function seed(){
    COUNT=Math.round(Math.max(170,Math.min(560,(W*H)/2200)));   // density scales with area
    ps.length=0; for(var i=0;i<COUNT;i++){ var p={}; reset(p,false); ps.push(p); }
  }
  function resize(){
    var rect=stage.getBoundingClientRect();
    dpr=Math.min(window.devicePixelRatio||1,2);                 // cap DPR for perf
    W=Math.max(1,Math.round(rect.width)); H=Math.max(1,Math.round(rect.height));
    canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
    canvas.style.width=W+'px'; canvas.style.height=H+'px';
    ctx.setTransform(dpr,0,0,dpr,0,0);                          // draw in CSS pixels
    var wide=W>=760; cx=W*(wide?0.66:0.5); cy=H*(wide?0.45:0.40); // offset vanishing point
    maxR=Math.hypot(Math.max(cx,W-cx),Math.max(cy,H-cy))*1.08;
    minR=Math.max(2,Math.min(W,H)*0.004);
    ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03040c'; ctx.fillRect(0,0,W,H);
    seed(); if(reduce) drawStatic();
  }
  function core(){                                              // glowing hot vanishing point
    var R=Math.min(W,H)*0.30, g=ctx.createRadialGradient(cx,cy,0,cx,cy,R);
    g.addColorStop(0,'rgba(255,255,255,0.30)'); g.addColorStop(0.18,'rgba(186,120,255,0.20)');
    g.addColorStop(0.5,'rgba(78,42,160,0.08)'); g.addColorStop(1,'rgba(78,42,160,0)');
    ctx.fillStyle=g; ctx.beginPath(); ctx.arc(cx,cy,R,0,Math.PI*2); ctx.fill();
  }
  function step(dt){
    ctx.globalCompositeOperation='source-over';                // fade prior frame -> trails
    ctx.fillStyle='rgba(3,4,12,0.15)'; ctx.fillRect(0,0,W,H);
    ctx.globalCompositeOperation='lighter'; ctx.lineCap='round'; // additive glow
    for(var i=0;i<COUNT;i++){
      var p=ps[i], depth=1-(p.r/maxR); if(depth<0)depth=0;      // 0 rim -> ~1 centre
      var pr=p.r, pa=p.a;
      p.a+=(0.010+depth*depth*0.055)*p.spin*dt;                 // spin accelerates inward
      p.r-=(0.45+depth*3.6)*dt;                                 // pull accelerates inward
      if(p.fade<1){ p.fade+=0.05*dt; if(p.fade>1)p.fade=1; }
      if(p.r<=minR){ reset(p,true); continue; }
      var px=cx+Math.cos(pa)*pr, py=cy+Math.sin(pa)*pr;
      var nx=cx+Math.cos(p.a)*p.r, ny=cy+Math.sin(p.a)*p.r;
      ctx.strokeStyle='hsla('+(p.hue+depth*132)+',92%,'+(56+depth*22)+'%,'+((0.18+depth*0.5)*p.fade)+')';
      ctx.lineWidth=p.w*(0.30+(1-depth)*1.05);                  // thins toward the centre
      ctx.beginPath(); ctx.moveTo(px,py); ctx.lineTo(nx,ny); ctx.stroke();
    }
    core();
  }
  function loop(now){ if(!running)return; var dt=(now-last)/16.667; last=now; if(dt>3)dt=3; if(dt<0)dt=0; step(dt); raf=requestAnimationFrame(loop); }
  function drawStatic(){                                        // reduced-motion: frozen spiral
    ctx.globalCompositeOperation='source-over'; ctx.fillStyle='#03040c'; ctx.fillRect(0,0,W,H);
    ctx.globalCompositeOperation='lighter';
    for(var s=0;s<3;s++) for(var i=0;i<150;i++){
      var t=i/150, depth=1-t, r=minR+Math.pow(t,1.12)*(maxR-minR), a=s*(Math.PI*2/3)+t*3.4*Math.PI*2;
      ctx.fillStyle='hsla('+(188+depth*132)+',92%,'+(56+depth*22)+'%,'+(0.20+depth*0.5)+')';
      ctx.beginPath(); ctx.arc(cx+Math.cos(a)*r,cy+Math.sin(a)*r,0.5+(1-depth)*2.0,0,Math.PI*2); ctx.fill();
    }
    core();
  }
  function start(){ if(running||reduce)return; running=true; last=performance.now(); raf=requestAnimationFrame(loop); }
  function stop(){ running=false; if(raf)cancelAnimationFrame(raf); raf=0; }
  function update(){ if(onscreen&&visible) start(); else stop(); }

  if('ResizeObserver' in window){ new ResizeObserver(resize).observe(stage); } else window.addEventListener('resize',resize);
  resize();
  if(reduce) return;                                            // static frame already drawn
  if('IntersectionObserver' in window){
    new IntersectionObserver(function(es){ onscreen=es[0].isIntersecting; update(); },{threshold:0.01}).observe(stage);
  }
  document.addEventListener('visibilitychange',function(){ visible=!document.hidden; update(); });
  update();
})();
```

## How it works

- **Polar motion.** Each particle is stored as an angle `a` and radius `r` from the vanishing point. Every frame the radius shrinks and the angle advances — the combination traces a spiral.
- **Acceleration inward.** Both the radial pull (`0.45 + depth*3.6`) and the angular speed (`0.010 + depth²*0.055`) grow with `depth` (how close to the centre it is), so particles whip up as they get sucked in — the "event horizon" feel. When `r` reaches `minR` the particle respawns at the rim.
- **Streaks, not dots.** Each particle is drawn as a short line from its *previous* position to its *current* one. Faster particles (near the centre) produce longer streaks — instant motion-blur.
- **Trails via fade.** The canvas is never cleared; each frame paints a translucent dark rect (`rgba(3,4,12,0.15)`) over everything, so old streaks decay instead of vanishing. `globalCompositeOperation='lighter'` makes overlapping streaks add up to white, which auto-builds the hot core.
- **Colour by depth.** Hue runs cyan→violet→magenta as `depth` rises, with lightness climbing toward the centre, so the tunnel glows hotter the deeper you look.
- **DPR-aware.** The backing store is sized `W*dpr × H*dpr` (DPR capped at 2) and the context is scaled once with `setTransform`, so all drawing math stays in CSS pixels while staying crisp on Retina.

## Customization

- **Intensity / trail length:** lower the fade alpha (`rgba(3,4,12,0.15)` → `0.08`) for long ghostly streaks; raise it (`0.30`) for short, snappy ones.
- **Spin vs. fall:** increase the `depth*depth*0.055` term for a tighter, more violent whirlpool; increase `depth*3.6` to make particles dive in faster (fewer visible arms).
- **Density:** change the `(W*H)/2200` divisor — smaller = more particles (watch performance).
- **Palette:** edit the base hue `188` and the `+depth*132` span; swap the `core()` gradient stops to retint the vanishing point.
- **Direction:** flip the sign of the `p.a +=` term to reverse the spin; move `cx/cy` to reposition the vanishing point (centre it for a symmetric tunnel).

## Accessibility & performance

- **Reduced motion:** `matchMedia('(prefers-reduced-motion: reduce)')` is checked up front; when set, `drawStatic()` paints a single frozen spiral and **no animation loop ever starts**.
- **Off-screen pause:** an `IntersectionObserver` stops the `requestAnimationFrame` loop when the stage scrolls out of view, and `visibilitychange` pauses it when the tab is hidden — no wasted GPU/battery.
- **DPR cap at 2** prevents 3×/4× phones from quadrupling fill cost; particle count scales with area, not a fixed huge number.
- **Legibility:** the vanishing point is offset and a dark `.vx-scrim` gradient sits between canvas and text — never place body copy directly over the bright core. The canvas is `aria-hidden` (decorative).

## Gotchas

- **Clearing the canvas kills the effect.** Use the translucent fill, not `clearRect` — the trails *are* the spiral arms.
- **Set `globalCompositeOperation` every frame.** `step()` switches to `source-over` for the fade rect then back to `lighter`; forget the reset and the fade itself turns additive (the screen blows out to white).
- **DPR sizing must redraw an opaque base.** After resizing the backing store the canvas is transparent — `resize()` repaints `#03040c` once so the first fades have something to blend into.
- **`setTransform` resets the matrix; don't double-apply DPR.** Drawing coordinates are CSS pixels because the context is pre-scaled — never multiply positions by `dpr` again.
- **Additive blending only looks right on a dark stage.** On a light background the `lighter` streaks barely register; keep the backdrop near-black.
