Aurora hero

Aurora hero

awwwards

Northern-lights WebGL shader with starfield. Hero-ready.

An aurora borealis effect rendered in a fragment shader. Multiple flowing color bands (green-teal + violet-purple) drift across a starry night sky. Hero-ready atmospheric background that doesn't repeat or look like a cliché AI gradient.

animatedwebgleffectbackgroundshaderhero

━━ Use this pattern

━━ Live preview

━━ HTML markup

<section class="au-stage">
  <canvas class="au-canvas"></canvas>
  <div class="au-content">
    <h1 class="au-title">A new frontier of design</h1>
    <p class="au-sub">— Aurora hero · WebGL shader</p>
  </div>
</section>

━━ CSS

.au-stage {
  position: relative;
  min-height: 100vh;
  overflow: hidden;
  /* Dark ground derived from the spec (stageMode "dark"): the bands are additive light,
     so they need a night field to read as aurora rather than as smudge. */
  --au-ink: color-mix(in oklab, var(--color-on-surface, #050510) 92%, var(--color-surface, #050510));
  /* The shader reads these four through the runtime probe, so the animated sky and the
     reduced-motion gradient below are the same palette rather than two hand-matched sets.
     Each carries its own original value as the fallback on BOTH sides of the mix. */
  --au-sky-lo: var(--au-ink);
  --au-sky-hi: color-mix(in oklab, var(--color-on-surface, #0d051f) 88%, var(--color-secondary, var(--color-primary, #0d051f)));
  --au-band-a: color-mix(in oklab, var(--color-primary, #66f2b2) 80%, var(--color-surface, #66f2b2));
  --au-band-b: color-mix(in oklab, var(--color-secondary, var(--color-primary, #9966ff)) 80%, var(--color-surface, #9966ff));
  /* Headline halo — its own literal, one step off the band because it was tuned by eye. */
  --au-glow: color-mix(in oklab, var(--color-secondary, var(--color-primary, #9664ff)) 80%, var(--color-surface, #9664ff));
  /* Still frames of the two band colours over the ink, for the reduced-motion gradient. */
  --au-veil-a: color-mix(in oklab, var(--color-on-surface, #1a0d3a) 88%, var(--color-secondary, var(--color-primary, #1a0d3a)));
  --au-veil-b: color-mix(in oklab, var(--color-on-surface, #0d2e3a) 88%, var(--color-primary, #0d2e3a));
  background: var(--au-ink);
  display: flex;
  align-items: center;
  justify-content: center;
}
@supports (color: oklch(from red min(l, 1 - l) c h)) {
  .au-stage {
    --au-ds-ground: oklch(from color-mix(in oklab, var(--color-on-surface) 92%, var(--color-surface)) min(l, 1 - l) c h);
    --au-ds-shade: oklch(from var(--color-on-surface) min(l, 1 - l) c h);
    --au-ds-ink: oklch(from var(--color-surface) max(l, 1 - l) c h);
    --au-ink: var(--au-ds-ground, color-mix(in oklab, var(--color-on-surface, #050510) 92%, var(--color-surface, #050510)));
    --au-sky-hi: color-mix(in oklab, var(--au-ds-shade, #0d051f) 88%, var(--color-secondary, var(--color-primary, #0d051f)));
    --au-band-a: color-mix(in oklab, var(--color-primary, #66f2b2) 80%, var(--au-ds-ink, #66f2b2));
    --au-band-b: color-mix(in oklab, var(--color-secondary, var(--color-primary, #9966ff)) 80%, var(--au-ds-ink, #9966ff));
    --au-glow: color-mix(in oklab, var(--color-secondary, var(--color-primary, #9664ff)) 80%, var(--au-ds-ink, #9664ff));
    --au-veil-a: color-mix(in oklab, var(--au-ds-shade, #1a0d3a) 88%, var(--color-secondary, var(--color-primary, #1a0d3a)));
    --au-veil-b: color-mix(in oklab, var(--au-ds-shade, #0d2e3a) 88%, var(--color-primary, #0d2e3a));
  }
}
.au-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
}
.au-content {
  position: relative;
  z-index: 1;
  text-align: center;
  padding: 0 5%;
  /* stageMode "dark": content sits on the dark stage, so it takes the SURFACE colour. */
  color: var(--color-surface, #fff);
}
@supports (color: oklch(from red min(l, 1 - l) c h)) {
  .au-content {
    --au-ds-ink: oklch(from var(--color-surface) max(l, 1 - l) c h);
    color: var(--au-ds-ink, #fff);
  }
}
.au-title {
  font-family: 'Geist', sans-serif;
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  margin: 0 0 1rem;
  text-shadow: 0 0 40px color-mix(in oklab, var(--au-glow) 40%, transparent);
}
.au-sub {
  font-family: 'Geist Mono', monospace;
  font-size: 0.8rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.7;
}
@media (prefers-reduced-motion: reduce) {
  .au-canvas { display: none; }
  /* Static fallback: the same ink and the same two band hues, held still. */
  .au-stage { background: linear-gradient(180deg,
    var(--au-ink) 0%, var(--au-veil-a) 40%, var(--au-veil-b) 70%, var(--au-ink) 100%); }
}

━━ JavaScript

(function(){
  const canvas = document.querySelector('.au-canvas');
  if (!canvas) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  if (!gl) return;

  const vs = `attribute vec2 a_pos; void main(){ gl_Position = vec4(a_pos, 0.0, 1.0); }`;

  const fs = `precision mediump float;
  uniform float u_time;
  uniform vec2 u_res;
  uniform vec3 u_sky0;   // --au-sky-lo
  uniform vec3 u_sky1;   // --au-sky-hi
  uniform vec3 u_bandA;  // --au-band-a
  uniform vec3 u_bandB;  // --au-band-b

  float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
  float noise(vec2 p){
    vec2 i = floor(p), f = fract(p);
    float a = hash(i), b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0)), d = hash(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
  }
  float fbm(vec2 p){
    float v = 0.0, a = 0.5;
    for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
    return v;
  }

  void main(){
    vec2 uv = gl_FragCoord.xy / u_res;
    float t = u_time * 0.30;   // was 0.08 — far too slow

    vec3 sky = mix(u_sky0, u_sky1, uv.y);

    float band1 = fbm(vec2(uv.x * 3.0 + t, uv.y * 1.5 - t * 0.5));
    float band2 = fbm(vec2(uv.x * 5.0 - t * 0.7, uv.y * 2.0 + t * 0.3));

    float vMask = smoothstep(0.1, 0.5, uv.y) * (1.0 - smoothstep(0.5, 0.95, uv.y));

    float aurora1 = pow(band1, 2.0) * vMask * 1.9;
    float aurora2 = pow(band2, 3.0) * vMask * 1.5;

    vec3 colA = u_bandA * aurora1;
    vec3 colB = u_bandB * aurora2;

    vec3 col = sky + colA + colB;

    // Twinkling star field
    float tw = 0.6 + 0.4 * sin(u_time * 2.0 + hash(floor(uv * u_res * 0.5)) * 30.0);
    float stars = step(0.995, hash(floor(uv * u_res * 0.5)));
    col += stars * 0.7 * tw * (1.0 - uv.y * 0.5);

    gl_FragColor = vec4(col, 1.0);
  }`;

  function compile(src, type){
    const sh = gl.createShader(type);
    gl.shaderSource(sh, src);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(sh)); return null; }
    return sh;
  }
  const prog = gl.createProgram();
  const v = compile(vs, gl.VERTEX_SHADER);
  const f = compile(fs, gl.FRAGMENT_SHADER);
  if (!v || !f) return;
  gl.attachShader(prog, v);
  gl.attachShader(prog, f);
  gl.linkProgram(prog);
  gl.useProgram(prog);

  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
  const a_pos = gl.getAttribLocation(prog, 'a_pos');
  gl.enableVertexAttribArray(a_pos);
  gl.vertexAttribPointer(a_pos, 2, gl.FLOAT, false, 0, 0);

  const u_time = gl.getUniformLocation(prog, 'u_time');
  const u_res = gl.getUniformLocation(prog, 'u_res');

  const stage = canvas.closest('.au-stage') || canvas.parentElement;
  // ── Token resolution ───────────────────────────────────────────────────
  // Canvas and WebGL take numbers, not var(). Resolve the tokens once, through the
  // browser: a hidden probe forces the cascade to compute the declaration, so
  // color-mix() and any token indirection arrive here already serialized as
  // rgb()/rgba(). With no tokens declared every fallback is the pattern's original
  // literal, so the painted result is unchanged.
  const TOKEN_ROOT = (stage) || document.body;
  function computed(prop, decl, fallback){
    const probe = document.createElement('span');
    probe.style.cssText = 'position:absolute;left:-9999px;width:0;height:0;';
    probe.style.setProperty(prop, decl);
    (TOKEN_ROOT || document.body).appendChild(probe);
    const v = getComputedStyle(probe)[prop];
    probe.remove();
    return v || fallback;
  }
  function rgbOf(decl, fallback){
    const v = computed('color', decl, null);
    if (!v) return fallback;
    // DO NOT parse the numbers out of this string. A color-mix(in oklab, ...) serializes
    // as 'oklab(L a b)', so a naive match() would read lightness as a red channel and
    // every derived colour would collapse to near-black. Paint it and read the real
    // 8-bit sRGB back instead — that works for rgb(), oklab(), lab(), color(), any of it.
    try {
      const c = document.createElement('canvas');
      c.width = 1; c.height = 1;
      const x = c.getContext('2d', { willReadFrequently: true });
      x.fillStyle = '#000';
      x.fillStyle = v;               // an unparseable value leaves fillStyle at #000
      x.fillRect(0, 0, 1, 1);
      const d = x.getImageData(0, 0, 1, 1).data;
      return [ d[0], d[1], d[2] ];
    } catch (e) {}
    // No 2D context (or a hostile embed). Fall back to the legacy rgb() shape, which is
    // still what a plain var() with a hex fallback resolves to.
    const m = String(v).match(/^rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
    return m ? [ +m[1], +m[2], +m[3] ] : fallback;
  }
  function rgb01(decl, fallback){
    const c = rgbOf(decl, null);
    return c ? [ c[0]/255, c[1]/255, c[2]/255 ] : fallback;
  }
  function hexOf(decl, fallback){
    const c = rgbOf(decl, null);
    return c ? ((c[0]|0) << 16) + ((c[1]|0) << 8) + (c[2]|0) : fallback;
  }
  // Four constant colours, uploaded once. They mirror the stage's own custom properties,
  // so the reduced-motion gradient in CSS and the live sky can never drift apart.
  gl.uniform3fv(gl.getUniformLocation(prog, 'u_sky0'),  rgb01('var(--au-sky-lo, #050510)', [0.02, 0.02, 0.06]));
  gl.uniform3fv(gl.getUniformLocation(prog, 'u_sky1'),  rgb01('var(--au-sky-hi, #0d051f)', [0.05, 0.02, 0.12]));
  gl.uniform3fv(gl.getUniformLocation(prog, 'u_bandA'), rgb01('var(--au-band-a, #66f2b2)', [0.4, 0.95, 0.7]));
  gl.uniform3fv(gl.getUniformLocation(prog, 'u_bandB'), rgb01('var(--au-band-b, #9966ff)', [0.6, 0.4, 1.0]));

  function resize(){
    const dpr = Math.min(window.devicePixelRatio, 2);
    canvas.width = canvas.clientWidth * dpr;
    canvas.height = canvas.clientHeight * dpr;
    gl.viewport(0, 0, canvas.width, canvas.height);
  }
  resize();
  window.addEventListener('resize', resize);

  let start = performance.now();
  function tick(){
    const t = (performance.now() - start) / 1000;
    gl.uniform1f(u_time, t);
    gl.uniform2f(u_res, canvas.width, canvas.height);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    requestAnimationFrame(tick);
  }
  tick();
})();
Aurora hero · CreativePower