Light Curtain

An aurora hanging from the top edge, broad bands of colour leaning across each other and falling away down the page.

Loading preview

Installation

npx shadcn@latest add @loomui/light-curtain

Usage

import { LightCurtain } from "@/components/ui/light-curtain"
<section className="relative overflow-hidden">
  <LightCurtain className="-z-10" />
 
  <h1>Where design meets production</h1>
</section>

It fills whatever it is inside, so the parent needs position: relative and overflow: hidden. It is aria-hidden and takes no pointer events, so it never gets in the way of what is on top of it.

It shares its palette with Grid Beams, so the two can sit on one page without arguing.

How it works

One sheet, not a row of beams

The bands are wide and overlapping on purpose. Each one is far wider than its own share of the row, so they always add up to a single wash rather than to five things standing next to each other.

Narrow bands read as separate beams, and separate beams that come and go read as something loading. An aurora is one moving sheet. What moves here is the shape of the sheet, not any object in it: each band widens, narrows and leans on its own clock, and the silhouette they add up to is never the same twice.

0%,
100% {
  translate: calc(var(--curtain-drift) * -1) 0;
  scale: var(--curtain-narrow) 1;
}
50% {
  translate: var(--curtain-drift) 0;
  scale: var(--curtain-wide) 1;
}

Both ends of the run are identical, so the loop has no seam and nothing ever blinks out and starts again.

Rooted at the top edge

Each band is pinned to the top and scaled from it, so widening never lifts it off the edge the light comes in at. The fall-off down the page is the gradient's and the layer mask's, not the animation's, so there is no edge to time.

Soft by construction, and blurred once

Each band is a radial gradient that is already soft on every side, and there is one blur over the whole sheet rather than one per band. That single pass is what turns five gradients into a wash, and it is static: the bands lean underneath it on transforms that never re-run it.

Blurring each band separately instead would cost a filter pass per band and pin every one of them to its own layer.

Seeded, so nothing teleports

Sizes, colours, offsets and timings all come out of one seeded generator.

const random = createRandom(seed)

Real randomness would give the server one wash and the browser another, and then a third the next time anything above it re-rendered. Same seed, same aurora, every time. Change seed to draw a different one.

No two are in step

Each band gets its own duration, off a multiplier that is never a round number, and a negative delay so it is already part way through on the first frame.

duration: duration * (0.7 + random() * 0.7),
delay: -random() * duration * 2,

Given one duration they would lean as a single object within a few cycles, and a sheet that breathes as one is a flashing rectangle.

It stops when nobody is looking

CSS animations keep running while they are scrolled away, and the compositor keeps the layers alive to do it. The layer watches for itself and pauses when it is off screen.

Props

LightCurtain takes everything a div takes, plus:

PropTypeDefaultDescription
bandsnumber5How many bands are layered into the wash.
colorsstring[]cyan, sky, purple, pinkDrawn from, one picked per column.
durationnumber16Seconds for one lean out and back.
driftstring"5rem"How far a band drifts sideways.
reachstring"85%"How far down the wash reaches.
intensitynumber0.55Brightest the wash gets.
blurnumber64Softening pass over the layer, in pixels.
fadeboolean | stringtrueFade downwards, or pass a CSS mask.
seednumber1Same seed, same aurora.
disabledbooleanfalseRender the wash still.

Reduced motion

With reduced motion the wash is drawn where it stands and nothing leans. The layer is decorative, so there is nothing to miss.