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.
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.
Three plates, one pivot
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.
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,
)Color defaults to var(--primary). Intensity (0.6 default) sets the fan
opacity.
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
The sweep is tiny on purpose
Rest vs live. The live plate runs animate-light-rays-sweep:
@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 */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.
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
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.
<feTurbulence
type="fractalNoise"
baseFrequency="0.9"
numOctaves={2}
stitchTiles="stitch"
/>
// rect opacity = grain, mix-blend-overlayDefault 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
The result
Three layers, one keyframe, optional grit — compositor-only the whole way.