Light Curtain
An aurora hanging from the top edge, broad bands of colour leaning across each other and falling away down the page.
Installation
npx shadcn@latest add @loomui/light-curtainUsage
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:
| Prop | Type | Default | Description |
|---|---|---|---|
bands | number | 5 | How many bands are layered into the wash. |
colors | string[] | cyan, sky, purple, pink | Drawn from, one picked per column. |
duration | number | 16 | Seconds for one lean out and back. |
drift | string | "5rem" | How far a band drifts sideways. |
reach | string | "85%" | How far down the wash reaches. |
intensity | number | 0.55 | Brightest the wash gets. |
blur | number | 64 | Softening pass over the layer, in pixels. |
fade | boolean | string | true | Fade downwards, or pass a CSS mask. |
seed | number | 1 | Same seed, same aurora. |
disabled | boolean | false | Render 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.