Anatomy of Light Rays
How a top glow, a repeating-conic fan, a ±6° breathe, and optional film grain stack into volumetric god-rays — all pure CSS.
↓Scroll to step through it
Three plates, one pivot
LightRays is three stacked layers and one keyframe. No canvas, no rAF —
just paint, an oversized transform, and an optional SVG noise overlay. Drop
it first in a relative overflow-hidden container.
Read left → right. Every plate shares the same top-center origin:
- Source — soft ellipse parked just above the frame (
50% -5%). - Wedges — sharp
repeating-conic-gradientso you can count spokes (step = 360 / rayCount, default14). - Soft fan — those wedges at
inset-[-50%],blur(8px), faded with a bottom mask. Oversizing is why a ±6° rotate never flashes an empty edge.
Color defaults to var(--primary). Intensity (0.6 default) sets the fan
opacity.
The sweep is tiny on purpose
Rest vs live. The live plate runs animate-light-rays-sweep:
style={{ "--rays-speed": `${14 / speed}s`, "--rays-angle": `${angle}deg`, … }}The needle in the diagram tracks that same arc so the motion is not lost in
the blur. motion-reduce:animate-none freezes the fan — glow and wedges
stay.
Grain is an overlay, not a texture bake
Clean fan → feTurbulence field → both with mix-blend-overlay. Default
grain={0.05} is almost subliminal; set 0 to omit the SVG entirely.
The result
Three layers, one keyframe, optional grit — compositor-only the whole way.
Same top pivot in every plate. Plate 3 is plate 2 oversized, blurred, and faded — that's the volumetric look.
- Source
- ellipse glow parked just above the top edge
- Wedges
- repeating-conic · one spoke every 360° / rayCount
- Soft fan
- inset −50% · blur(8px) · mask fades downward
const step = 360 / rayCount;const rayColor = `color-mix(in oklab, ${color}, transparent 55%)`;repeating-conic-gradient(from ${angle}deg at 50% 0%,transparent 0deg,${rayColor} ${step * 0.16}deg,transparent ${step}deg,)Anatomy of Light Rays
How a top glow, a repeating-conic fan, a ±6° breathe, and optional film grain stack into volumetric god-rays — all pure CSS.
Same top pivot in every plate. Plate 3 is plate 2 oversized, blurred, and faded — that's the volumetric look.
- Source
- ellipse glow parked just above the top edge
- Wedges
- repeating-conic · one spoke every 360° / rayCount
- Soft fan
- inset −50% · blur(8px) · mask fades downward
const step = 360 / rayCount;const rayColor = `color-mix(in oklab, ${color}, transparent 55%)`;repeating-conic-gradient(from ${angle}deg at 50% 0%,transparent 0deg,${rayColor} ${step * 0.16}deg,transparent ${step}deg,)Three plates, one pivot
LightRays is three stacked layers and one keyframe. No canvas, no rAF —
just paint, an oversized transform, and an optional SVG noise overlay. Drop
it first in a relative overflow-hidden container.
Read left → right. Every plate shares the same top-center origin:
- Source — soft ellipse parked just above the frame (
50% -5%). - Wedges — sharp
repeating-conic-gradientso you can count spokes (step = 360 / rayCount, default14). - Soft fan — those wedges at
inset-[-50%],blur(8px), faded with a bottom mask. Oversizing is why a ±6° rotate never flashes an empty edge.
Color defaults to var(--primary). Intensity (0.6 default) sets the fan
opacity.
The needle tracks the same keyframe as the fan: rotate ±6°, scale up to 1.08, opacity breathes. Duration 14s / speed, alternate forever — frozen under motion-reduce.
- Transform
- rotate(±6deg) scale(1→1.08)
- Timing
- ease-in-out alternate · --rays-speed
@keyframes light-rays-sweep {0% { transform: rotate(calc(var(--rays-angle) - 6deg)) scale(1); opacity: calc(var(--rays-intensity) * 0.7); }100% { transform: rotate(calc(var(--rays-angle) + 6deg)) scale(1.08); opacity: var(--rays-intensity); }}/* duration: var(--rays-speed, 14s) = 14 / speed · ease-in-out alternate */The sweep is tiny on purpose
Rest vs live. The live plate runs animate-light-rays-sweep:
style={{ "--rays-speed": `${14 / speed}s`, "--rays-angle": `${angle}deg`, … }}The needle in the diagram tracks that same arc so the motion is not lost in
the blur. motion-reduce:animate-none freezes the fan — glow and wedges
stay.
Default grain={0.05} is subtle on purpose — bump it in the Result to feel the film grit. Set 0 to drop the SVG layer entirely.
- Noise
- fractalNoise · baseFrequency 0.9
- Blend
- mix-blend-overlay · opacity = grain
<feTurbulencetype="fractalNoise"baseFrequency="0.9"numOctaves={2}stitchTiles="stitch"/>// rect opacity = grain, mix-blend-overlayGrain is an overlay, not a texture bake
Clean fan → feTurbulence field → both with mix-blend-overlay. Default
grain={0.05} is almost subliminal; set 0 to omit the SVG entirely.
The result
Three layers, one keyframe, optional grit — compositor-only the whole way.
Motion Score
transformCompositor transform (translate / scale / rotate)opacityFade / cross-fadefilterFilter (blur / brightness)