Anatomy of Light Rays
How a top glow, a repeating-conic fan, a ±6° breathe, and optional film grain stack into volumetric god-rays in pure CSS.
↓Scroll to step through it
Three plates, one pivot
LightRays is three stacked layers and one keyframe. No canvas and no rAF.
It uses 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: a soft ellipse parked just above the frame (
50% -5%). - Wedges: a 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; the 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 barely visible; set 0 to omit the SVG entirely.
The result
Three layers, one keyframe, and optional grain. Every animated property is compositor-only.
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 in 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 and no rAF.
It uses 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: a soft ellipse parked just above the frame (
50% -5%). - Wedges: a 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; the glow and wedges
stay.
Default grain={0.05} is subtle on purpose. Raise it in the Result to see the film grain. 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 barely visible; set 0 to omit the SVG entirely.
The result
Three layers, one keyframe, and optional grain. Every animated property is compositor-only.
Motion Score
transformCompositor transform (translate / scale / rotate)opacityFade / cross-fadefilterFilter (blur / brightness)