A night scene for a hero: a starry sky, a dark ridge with three lit cabins, a still lake and the northern lights hanging over all of it. The lights are drawn as curtains made of hundreds of thin vertical rays. Each curtain folds like fabric, and where a fold doubles back the rays overlap and glow brighter. Three curtains at different distances give depth, from a low teal band near the horizon to a tall green and violet one overhead. The lake mirrors the sky row by row with a moving ripple. The headline, copy and buttons sit in the dark water at the bottom, with a forecast card on wide screens. On phones the card is hidden and the buttons stack.
import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
/** Where the waterline sits, as a share of the hero height. */
const HORIZON = 0.58;
/** Overall speed of the lights. 1 is calm, 2 is a lively night. */
const SPEED = 1;
/** How strongly the lake mirrors the sky (0 to 1) and how far the ripples smear it, in pixels. */
const REFLECTION = 0.62;
const RIPPLE = 5;
/** Gap between two light rays in canvas pixels. Smaller is silkier and costs more per frame. */
const RAY_STEP = 2;
// Three curtains, far to near. `hem` is how high the bottom edge hangs (share of the sky), `rays` how tall the
// light reaches, `folds` how many pleats cross the screen, `sway` how far each pleat swings sideways,
// `tilt` how much the hem slopes from one side of the sky to the other.
const curtains = [
{ hem: 0.86, rays: 0.3, folds: 2.4, sway: 0.035, tilt: -0.06, speed: 0.05, glow: 0.55, stops: [[0, "56,189,248", 0], [0.5, "56,189,248", 0.12], [0.84, "52,211,153", 0.8], [1, "52,211,153", 0]] },
{ hem: 0.7, rays: 0.5, folds: 1.7, sway: 0.06, tilt: 0.2, speed: 0.07, glow: 0.8, stops: [[0, "129,140,248", 0], [0.35, "129,140,248", 0.14], [0.68, "45,212,191", 0.42], [0.88, "110,255,190", 1], [1, "110,255,190", 0]] },
{ hem: 0.5, rays: 0.62, folds: 1.15, sway: 0.09, tilt: -0.3, speed: 0.1, glow: 1, stops: [[0, "192,90,255", 0], [0.3, "200,95,255", 0.2], [0.62, "70,225,185", 0.42], [0.86, "130,255,175", 1], [0.93, "215,255,225", 1], [1, "215,255,225", 0]] },
] as const;
const forecast = [
{ label: "Activity", value: "Kp 6, strong" },
{ label: "Peak", value: "23:40" },
{ label: "Cloud cover", value: "8%" },
];
const cabins = [66, 71.5, 80];
/** Repeatable random number between 0 and 1. */
const hash = (n: number): number => {
const s = Math.sin(n * 91.345 + 47.1) * 43758.5453;
return s - Math.floor(s);
};
/** Smooth one dimensional noise between 0 and 1. */
const wave = (x: number): number => {
const i = Math.floor(x);
const f = x - i;
return hash(i) + (hash(i + 1) - hash(i)) * (f * f * (3 - 2 * f));
};
const ridge = "M0 100V64l52-14 44 12 62-34 58 26 66-18 62 26 70-22 68 28 62-20 82-34 78 40 70-16 72 24 78-30 72 26 68-16 62 18 62-24V100Z";
export default function NorthernLightsCurtainBackgroundWithWaterReflection() {
const reduce = !!useReducedMotion();
const rootRef = useRef<HTMLElement>(null);
const lightsRef = useRef<HTMLCanvasElement>(null);
const starsRef = useRef<HTMLCanvasElement>(null);
const twinkleRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = lightsRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
// One tall, one pixel wide strip of light per curtain. Every ray is this strip, stretched and faded.
const strips = curtains.map((c) => {
const s = document.createElement("canvas");
s.width = 1;
s.height = 128;
const g = s.getContext("2d");
if (g) {
const grad = g.createLinearGradient(0, 0, 0, 128);
for (const [at, rgb, a] of c.stops) grad.addColorStop(at, `rgba(${rgb},${a})`);
g.fillStyle = grad;
g.fillRect(0, 0, 1, 128);
}
return s;
});
let w = 0;
let h = 0;
let sky = 0;
// The sky is painted on a canvas of its own, so the lake can copy from a finished picture.
const above = document.createElement("canvas");
const pen = above.getContext("2d");
let frame = 0;
let onScreen = true;
const began = performance.now();
const draw = (now: number) => {
const t = ((now - began) / 1000) * SPEED + 40;
if (!pen) return;
pen.globalCompositeOperation = "source-over";
pen.globalAlpha = 1;
pen.clearRect(0, 0, w, sky);
// Light adds up: where a pleat doubles back on itself the rays overlap and the curtain burns brighter.
pen.globalCompositeOperation = "lighter";
const step = RAY_STEP;
curtains.forEach((c, n) => {
const count = Math.ceil((w * 1.3) / step);
for (let i = 0; i < count; i++) {
const s = -0.15 + (i / count) * 1.3;
const fold = s * c.folds * Math.PI * 2 + t * c.speed * Math.PI * 2 + n * 1.7;
const x = s * w + Math.sin(fold) * c.sway * w + Math.sin(s * 9 + t * 0.21 + n) * 0.012 * w;
// The hem tilts across the sky and rolls slowly, so each curtain reads as a long arc and not a level band.
const hem = sky * (c.hem + (s - 0.5) * c.tilt) + Math.cos(fold) * sky * 0.045 + Math.sin(s * 3.1 - t * 0.09 + n * 2) * sky * 0.07;
const tall = sky * c.rays * (0.55 + 0.45 * wave(s * 5 + t * 0.11 + n * 13));
const streak = 0.12 + 0.88 * wave(s * 85 + t * 0.55 + n * 31);
const lit = wave(s * 2.6 - t * 0.16 + n * 7);
const patch = 0.04 + 1.25 * lit * lit;
const edge = Math.min(1, (s + 0.15) * 6, (1.15 - s) * 6);
const light = c.glow * streak * streak * patch * edge;
if (light < 0.012) continue;
pen.globalAlpha = Math.min(1, light);
pen.drawImage(strips[n], x, hem - tall, RAY_STEP * 1.75, tall);
}
});
// The lake: copy the sky back in upside down, a thin row at a time, each row pushed sideways by a ripple.
ctx.globalAlpha = 1;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(above, 0, 0);
const water = h - sky;
for (let y = 0; y < water; y++) {
const d = y / water;
const from = sky - 3 - y * 1.25;
if (from < 0) break;
const shift = (Math.sin(y * 0.85 + t * 2.4) * (0.6 + d * RIPPLE) + Math.sin(y * 0.21 - t * 1.1) * d * RIPPLE) * 0.5;
ctx.globalAlpha = REFLECTION * (1 - d * 0.55);
ctx.drawImage(above, 0, from, w, 1, shift, sky + y, w, 1);
}
};
const paintStars = (target: HTMLCanvasElement | null, count: number, seed: number, cw: number, ch: number) => {
const g = target?.getContext("2d");
if (!target || !g) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
target.width = Math.round(cw * dpr);
target.height = Math.round(ch * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
for (let i = 0; i < count; i++) {
const r = 0.4 + hash(seed + i * 3) * hash(seed + i * 7) * 1.3;
g.fillStyle = `rgba(226,232,255,${(0.35 + hash(seed + i * 5) * 0.65).toFixed(2)})`;
g.beginPath();
g.arc(hash(seed + i) * cw, Math.pow(hash(seed + i * 11), 1.4) * ch * HORIZON * 0.97, r, 0, Math.PI * 2);
g.fill();
}
};
const loop = (now: number) => {
draw(now);
frame = requestAnimationFrame(loop);
};
const start = () => {
cancelAnimationFrame(frame);
if (reduce || !onScreen || document.hidden) return;
frame = requestAnimationFrame(loop);
};
const resize = () => {
const cw = root.clientWidth;
const ch = root.clientHeight;
// Half resolution on purpose: the lights are soft, and a smaller canvas is four times cheaper to fill.
w = Math.ceil(cw / 2);
h = Math.ceil(ch / 2);
sky = Math.round(h * HORIZON);
canvas.width = w;
canvas.height = h;
above.width = w;
above.height = sky;
paintStars(starsRef.current, Math.round(cw * 0.2), 1, cw, ch);
paintStars(twinkleRef.current, Math.round(cw * 0.07), 900, cw, ch);
draw(performance.now());
};
let lastW = 0;
let lastH = 0;
const sizes = new ResizeObserver(() => {
if (root.clientWidth === lastW && root.clientHeight === lastH) return;
lastW = root.clientWidth;
lastH = root.clientHeight;
resize();
});
sizes.observe(root);
const view = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
start();
});
view.observe(root);
document.addEventListener("visibilitychange", start);
start();
return () => {
cancelAnimationFrame(frame);
sizes.disconnect();
view.disconnect();
document.removeEventListener("visibilitychange", start);
};
}, [reduce]);
const rise = (delay: number) => ({
initial: reduce ? false : ({ opacity: 0, y: 18 } as const),
animate: { opacity: 1, y: 0 },
transition: { duration: 0.9, delay, ease: [0.22, 0.61, 0.36, 1] as const },
});
const horizon = `${HORIZON * 100}%`;
return (
<section ref={rootRef} className="relative h-[780px] w-full overflow-hidden bg-[#02040c] text-white sm:h-[720px]">
<style>{`
@keyframes nlc-twinkle { from { opacity: 0.15; } to { opacity: 1; } }
.nlc-twinkle { animation: nlc-twinkle 2.6s ease-in-out infinite alternate; }
@media (prefers-reduced-motion: reduce) { .nlc-twinkle { animation: none; } }
`}</style>
{/* Sky and lake are two plain gradients; everything that glows is drawn on the canvases above them. */}
<div className="absolute inset-x-0 top-0" style={{ height: horizon, background: "linear-gradient(180deg, #02040c 0%, #050d22 55%, #081a33 100%)" }} aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0" style={{ top: horizon, background: "linear-gradient(180deg, #071a30 0%, #030814 60%, #02040c 100%)" }} aria-hidden="true" />
<canvas ref={starsRef} className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true" />
<canvas ref={twinkleRef} className="nlc-twinkle pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true" />
<canvas ref={lightsRef} className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true" />
{/* Far shore: a ridge, its mirror image, and three cabins with lit windows. */}
<svg viewBox="0 0 1200 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-x-0 h-[56px] w-full -translate-y-full sm:h-[78px]" style={{ top: horizon }} aria-hidden="true">
<path d={ridge} fill="#030712" />
</svg>
<svg viewBox="0 0 1200 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-x-0 h-[44px] w-full -scale-y-100 opacity-80 blur-[1.5px] sm:h-[62px]" style={{ top: horizon }} aria-hidden="true">
<path d={ridge} fill="#030712" />
</svg>
{cabins.map((left, i) => (
<div key={left} className="pointer-events-none absolute hidden sm:block" style={{ left: `${left}%`, top: horizon }} aria-hidden="true">
<span className="absolute -top-[7px] left-0 h-[7px] w-[12px] bg-[#01030a]" />
<span className="absolute -top-[11px] left-0 h-0 w-0 border-x-[6px] border-b-[5px] border-x-transparent border-b-[#01030a]" />
<span className="absolute -top-[5px] left-[4px] h-[3px] w-[4px] bg-amber-300 shadow-[0_0_8px_2px_rgba(252,211,77,0.8)]" />
<span className="absolute left-[4px] top-[2px] w-[4px] rounded-full bg-gradient-to-b from-amber-300/80 to-transparent blur-[1.5px]" style={{ height: 26 + i * 8 }} />
</div>
))}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-3/5 bg-gradient-to-t from-[#02040c] via-[#02040c]/70 to-transparent sm:h-2/5 sm:via-[#02040c]/30" aria-hidden="true" />
<div className="absolute inset-x-0 bottom-0 z-10">
<div className="mx-auto flex w-full max-w-6xl items-end justify-between gap-10 px-6 pb-10 sm:px-10 sm:pb-12">
<div className="max-w-2xl">
<motion.p {...rise(0.1)} className="inline-flex items-center gap-2.5 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-200">
<span className="relative flex h-2 w-2" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-300 opacity-70 motion-reduce:animate-none" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-300" />
</span>
Visible tonight over Kvaloya
</motion.p>
<motion.h1 {...rise(0.2)} className="mt-4 text-5xl font-semibold leading-[1.02] tracking-tight sm:text-6xl lg:text-7xl">
Sleep under the lights.
</motion.h1>
<motion.p {...rise(0.32)} className="mt-5 max-w-lg text-base leading-relaxed text-slate-200 sm:text-lg">
Nine glass roofed cabins on a still fjord, 350 km above the Arctic Circle. We wake you when the sky starts.
</motion.p>
<motion.div {...rise(0.44)} className="mt-7 flex flex-col gap-3 sm:flex-row">
<a href="#" className="inline-flex h-12 items-center justify-center rounded-full bg-emerald-300 px-7 text-sm font-semibold text-slate-950 transition hover:bg-emerald-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#02040c]">
Check winter dates
</a>
<a href="#" className="inline-flex h-12 items-center justify-center rounded-full border border-white/30 px-7 text-sm font-semibold text-white transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#02040c]">
See the cabins
</a>
</motion.div>
</div>
<motion.div {...rise(0.6)} className="hidden w-64 shrink-0 rounded-2xl border border-white/15 bg-slate-950/50 p-5 backdrop-blur-md lg:block">
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">Aurora forecast</h2>
<dl className="mt-3 divide-y divide-white/10">
{forecast.map((f) => (
<div key={f.label} className="flex items-baseline justify-between py-2.5">
<dt className="text-sm text-slate-300">{f.label}</dt>
<dd className="text-sm font-semibold text-white">{f.value}</dd>
</div>
))}
</dl>
</motion.div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/northern-lights-curtain-background-with-water-reflection.json
Each curtain is a line of thin vertical strips. A strip is a small pre-rendered gradient, bright at the bottom and fading to nothing at the top, stretched to the height of that ray. The strip positions follow a wave that moves sideways as well as up and down, which makes the line fold over itself like hanging cloth.
The strips are drawn with additive blending, so overlapping light adds up. Where the curtain doubles back, several strips land on the same pixels and the sum is brighter, which is also what happens with a real aurora seen edge on.
The sky is drawn on a canvas of its own. The visible canvas then copies it into the lake area upside down, one row at a time. Each row is shifted sideways by a sine wave that grows towards the viewer, and faded a little more with distance. That is enough to read as rippling water.
Aurora light is soft, so nothing is lost by drawing it at half size and letting the browser stretch it. It cuts the pixels filled per frame to a quarter. The stars are on their own full resolution canvases so they stay sharp.
Yes. Set `HORIZON` to `1` and delete the two ridge SVGs and the cabins for sky only. To keep the ridge but change its outline, edit the `ridge` path, which is drawn in a 1200 by 100 box and stretched to the width.
Related: Aurora Background with Drifting Colour Bands, Starfield Background with Depth and Shooting Stars, Parallax Mountain Scene in a Scroll Frame.
More animated backgrounds or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.