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.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; }
}(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);
})();<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>