MynduiMyndui
0

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.

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, 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:

  1. Source — soft ellipse parked just above the frame (50% -5%).
  2. Wedges — 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 — glow and wedges stay.

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

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
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 almost subliminal; set 0 to omit the SVG entirely.

04/04Result
04

The result

Three layers, one keyframe, optional grit — compositor-only the whole way.

Motion Score

Light RaysSSCompositor-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 →