← Back to library

Aurora curtains

Vertical light curtains — northern-lights drape, not blobs.

Three curtains are accumulated additively, each one a sine along x whose phase is perturbed by its own noise sample so the folds wander independently rather than marching in step. A second smoothstep on y fades each drape from the top down, which is what gives the hanging, curtain-like read instead of a floating blob. Colour interpolates lime to amber across the three layers. Deliberately distinct from the existing aurora-hero pattern — that one pools, this one hangs and ripples.

shader-aurora-curtains · intensity awwwards

Base-catalogue pattern — no generated axes.

Generation prompts are part of a CreativePower subscription, and are never public.

See plans

Stylesheet

css · 4369 chars
.acu-stage {
  position: relative;
  min-height: 100vh;
  overflow: hidden;
  /* stageMode "dark": every Batch 02 effect is additive light, so the stage derives a
     dark ground FROM the spec (on-surface pulled 8% toward surface) rather than taking
     --color-surface directly. Both sides fall back to the same literal, so a token-less
     page renders exactly as before. */
  --acu-ink: color-mix(in oklab, var(--color-on-surface, #0a0a0a) 92%, var(--color-surface, #0a0a0a));
  /* Prefix-free alias, so a builder that doesn't know this stage's prefix (axes#fallbackFor,
     the shared lights() rig) can still read the derived ground. Element-scoped, so two
     Batch 02 stages on one page each see their own. */
  --tok-ink: var(--acu-ink);
  /* The four hue slots FS_HEAD declares, derived from the spec. Each mixes toward the
     surface BEFORE the field's own falloff, so a dark or cool primary doesn't render
     dimmer than the source tuning assumed; BLUE and VIO have no token of their own and
     blend the declared pair rather than inventing a hue. See ../shader-palette. */
  --acu-warm: color-mix(in oklab, var(--color-primary, #c8793a) 80%, var(--color-surface, #c8793a));
  --acu-bright: color-mix(in oklab, var(--color-secondary, var(--color-primary, #c8f564)) 80%, var(--color-surface, #c8f564));
  --acu-blue: color-mix(in oklab,
                color-mix(in oklab, var(--color-secondary, var(--color-primary, #456ec7)) 55%, var(--color-primary, #456ec7))
                80%, var(--color-surface, #456ec7));
  --acu-vio: color-mix(in oklab,
                color-mix(in oklab, var(--color-secondary, var(--color-primary, #9659c7)) 70%, var(--color-primary, #9659c7))
                80%, var(--color-surface, #9659c7));
  /* Also the graceful-degradation backdrop: the canvas paints over this when WebGL
     is available, so a blocked CDN or a failed context degrades to a static field
     rather than a black rectangle. */
  background:
    linear-gradient(180deg, color-mix(in oklab, var(--acu-bright) 26%, transparent), transparent 55%),
    radial-gradient(50% 60% at 30% 25%, color-mix(in oklab, var(--acu-bright) 22%, transparent), transparent),
    radial-gradient(50% 60% at 72% 30%, color-mix(in oklab, var(--acu-warm) 24%, transparent), transparent),
    var(--acu-ink);
  display: flex;
  align-items: center;
  justify-content: center;
}
@supports (color: oklch(from red min(l, 1 - l) c h)) {
  .acu-stage {
    --acu-ds-ground: oklch(from color-mix(in oklab, var(--color-on-surface) 92%, var(--color-surface)) min(l, 1 - l) c h);
    --acu-ds-ink: oklch(from var(--color-surface) max(l, 1 - l) c h);
    --acu-ink: var(--acu-ds-ground, color-mix(in oklab, var(--color-on-surface, #0a0a0a) 92%, var(--color-surface, #0a0a0a)));
    --acu-warm: color-mix(in oklab, var(--color-primary, #c8793a) 80%, var(--acu-ds-ink, #c8793a));
    --acu-bright: color-mix(in oklab, var(--color-secondary, var(--color-primary, #c8f564)) 80%, var(--acu-ds-ink, #c8f564));
    --acu-blue: color-mix(in oklab,
                color-mix(in oklab, var(--color-secondary, var(--color-primary, #456ec7)) 55%, var(--color-primary, #456ec7))
                80%, var(--acu-ds-ink, #456ec7));
    --acu-vio: color-mix(in oklab,
                color-mix(in oklab, var(--color-secondary, var(--color-primary, #9659c7)) 70%, var(--color-primary, #9659c7))
                80%, var(--acu-ds-ink, #9659c7));
  }
}
.acu-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: 0;
}
.acu-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, #f5f5f0);
  pointer-events: none;
}
@supports (color: oklch(from red min(l, 1 - l) c h)) {
  .acu-content {
    --acu-ds-ink: oklch(from var(--color-surface) max(l, 1 - l) c h);
    color: var(--acu-ds-ink, #f5f5f0);
  }
}
.acu-title {
  font-family: 'Geist', sans-serif;
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1.05;
  margin: 0 0 1rem;
}
.acu-sub {
  font-family: 'Geist Mono', monospace;
  font-size: 0.8rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.6;
}
@media (prefers-reduced-motion: reduce) {
  .acu-canvas { display: none; }
}

Script

js · 8188 chars
(function(){
  const stage = document.querySelector('.acu-stage');
  const canvas = document.querySelector('.acu-canvas');
  if (!stage || !canvas) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  if (typeof THREE === 'undefined') return;

  // ── 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;
  }
  // The renderer's ground and the shader's five colour slots come from the stage's own
  // custom properties, so the WebGL field and the CSS degradation backdrop behind it are
  // one palette rather than two hand-matched sets.
  const CLEAR = hexOf('var(--acu-ink, #0a0a0a)', 0x0a0a0a);
  function tokColor(decl, r, g, b){
    const c = rgb01(decl, [r, g, b]);
    return new THREE.Color(c[0], c[1], c[2]);
  }

  // ── GLSL — verbatim from motion-batch-02.html ──────────────────────────
  const QUAD_VS = `varying vec2 vUv;void main(){vUv=uv;gl_Position=vec4(position.xy,0.0,1.0);}`;

  const FS_HEAD = `precision highp float;varying vec2 vUv;
uniform float u_time;uniform vec2 u_res;uniform vec2 u_mouse;
uniform vec3 WARM;
uniform vec3 BRIGHT;
uniform vec3 BLUE;
uniform vec3 VIO;
uniform vec3 BG;
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453123);}
vec2 hash2(vec2 p){return fract(sin(vec2(dot(p,vec2(127.1,311.7)),dot(p,vec2(269.5,183.3))))*43758.5453);}
float noise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.0-2.0*f);
 float a=hash(i),b=hash(i+vec2(1.0,0.0)),c=hash(i+vec2(0.0,1.0)),d=hash(i+vec2(1.0,1.0));
 return mix(mix(a,b,f.x),mix(c,d,f.x),f.y);}
float fbm(vec2 p){float v=0.0,a=0.5;for(int i=0;i<5;i++){v+=a*noise(p);p=p*2.0+vec2(1.7,9.2);a*=0.5;}return v;}
vec2 aUV(vec2 uv){uv-=0.5;uv.x*=u_res.x/u_res.y;return uv;}`;

  // SHADERS[11] — "Aurora Curtains". Body only; wrapped below exactly as the
  // source's wrapFrag() does.
  const BODY = `
  vec2 p=aUV(uv);float t=u_time*0.2;vec3 col=BG;
  for(int i=0;i<3;i++){float fi=float(i);
   float x=p.x*2.0+fi*1.7;
   float band=sin(x+fbm(vec2(x,t+fi))*3.0+t);
   float inten=smoothstep(0.6,1.0,band)*smoothstep(0.5,-0.3,p.y+0.2*sin(x));
   col+=mix(BRIGHT,WARM,fi/2.0)*inten*0.85;}
  return col;`;

  const FRAG = FS_HEAD + '\nvec3 scene(vec2 uv){' + BODY + '}\nvoid main(){gl_FragColor=vec4(scene(vUv),1.0);}';

  // ── Renderer ───────────────────────────────────────────────────────────
  let renderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: false });
  } catch (e) {
    // No WebGL — leave the canvas unpainted so the stage's static gradient shows.
    canvas.style.display = 'none';
    return;
  }
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setClearColor(CLEAR, 1);

  function size(){
    return {
      w: stage.clientWidth || window.innerWidth,
      h: stage.clientHeight || window.innerHeight
    };
  }

  const dim = size();
  renderer.setSize(dim.w, dim.h, false);

  const scene = new THREE.Scene();
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  // u_mouse is part of the verbatim FS_HEAD but is not read by this shader
  // (only slides 1/3/7 in the source sheet are cursor-reactive), so it is pinned
  // rather than driven by a global pointermove listener.
  const uniforms = {
    u_time: { value: 0 },
    u_res: { value: new THREE.Vector2(dim.w, dim.h) },
    u_mouse: { value: new THREE.Vector2(0.5, 0.5) },
    WARM: { value: tokColor('var(--acu-warm, #c8793a)', 0.784, 0.475, 0.227) },
    BRIGHT: { value: tokColor('var(--acu-bright, #c8f564)', 0.784, 0.961, 0.392) },
    BLUE: { value: tokColor('var(--acu-blue, #456ec7)', 0.27, 0.43, 0.78) },
    VIO: { value: tokColor('var(--acu-vio, #9659c7)', 0.59, 0.35, 0.78) },
    BG: { value: tokColor('var(--acu-ink, #0a0a0a)', 0.039, 0.039, 0.039) }
  };
  const mat = new THREE.ShaderMaterial({
    uniforms: uniforms,
    vertexShader: QUAD_VS,
    fragmentShader: FRAG
  });
  scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat));

  function resize(){
    const d = size();
    renderer.setSize(d.w, d.h, false);
    uniforms.u_res.value.set(d.w, d.h);
  }
  window.addEventListener('resize', resize);

  // ── Context loss ───────────────────────────────────────────────────────
  // Browsers cap live WebGL contexts (commonly ~16). A long catalog page can
  // exceed that and this context may be force-lost. preventDefault() is what
  // makes restoration possible at all; hiding the canvas reveals the static
  // gradient painted behind it, so a lost stage degrades to the fallback field
  // instead of a black rectangle.
  let rafId = 0;
  let lost = false;

  canvas.addEventListener('webglcontextlost', function (e) {
    e.preventDefault();
    lost = true;
    if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
    canvas.style.opacity = '0';
  }, false);

  canvas.addEventListener('webglcontextrestored', function () {
    try {
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      renderer.setClearColor(CLEAR, 1);
      resize();
      // Force a program recompile; three re-uploads the quad's buffers itself.
      mat.needsUpdate = true;
      lost = false;
      canvas.style.opacity = '1';
      if (!rafId) rafId = requestAnimationFrame(frame);
    } catch (e) {
      // Restoration failed — stay on the static gradient.
      canvas.style.opacity = '0';
    }
  }, false);

  // ── Loop ───────────────────────────────────────────────────────────────
  // Honors the showcase pause bridge: when the stage is offscreen the GPU work
  // is skipped. The curtains ripple at u_time*0.2, so the clock discontinuity
  // on resume is not perceptible.
  const start = performance.now();
  function frame(now){
    rafId = 0;
    if (!lost && window.__cpPaused !== true) {
      uniforms.u_time.value = (now - start) * 0.001;
      try { renderer.render(scene, camera); } catch (e) {}
    }
    if (!lost) rafId = requestAnimationFrame(frame);
  }
  rafId = requestAnimationFrame(frame);
})();

Markup

html · 214 chars
<section class="acu-stage">
  <canvas class="acu-canvas"></canvas>
  <div class="acu-content">
    <h1 class="acu-title">Light that hangs</h1>
    <p class="acu-sub">— Aurora curtains · GLSL</p>
  </div>
</section>

Required CDN scripts