GodUIGodUI
129Follow on X

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.

01/04Three plates, one pivot
1 · source
2 · wedges
3 · soft

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
tsx
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,)
01

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:

  1. Source: a soft ellipse parked just above the frame (50% -5%).
  2. Wedges: a sharp repeating-conic-gradient so you can count spokes (step = 360 / rayCount, default 14).
  3. 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.

02/04The sweep is tiny on purpose
rest · 0°
sweep · ±6°

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
tsx
@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 */
02

The sweep is tiny on purpose

Rest vs live. The live plate runs animate-light-rays-sweep:

tsx
 
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.

03/04Grain is an overlay, not a texture bake
clean
feTurbulence
overlay

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
tsx
<feTurbulencetype="fractalNoise"baseFrequency="0.9"numOctaves={2}stitchTiles="stitch"/>// rect opacity = grain, mix-blend-overlay
03

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.

04/04Result
04

The result

Three layers, one keyframe, and optional grain. Every animated property is compositor-only.

Motion Score

Light RaysSS: Compositor-only
StransformCompositor transform (translate / scale / rotate)
SopacityFade / cross-fade
SfilterFilter (blur / brightness)
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →