Prism
One beam split at an implied prism edge into a slowly drifting fan of token-coloured bands — additive light on dark surfaces, subtractive ink on light ones.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/prism.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Prism" component — one beam of light
split at an implied prism edge into a fan of coloured bands, painted on a
single canvas and used as a hero / auth / 404 backdrop. Its only dependency is
a cn() class merger (clsx + tailwind-merge).
Contract
- export function Prism(props): props extend React.ComponentProps<"div">
(rest props and ref spread onto the root) plus:
- bands?: number (default 5) — how many bands the beam splits into, rounded
and clamped to 2..12. Two is the least that can read as a split; past
twelve the gaps close and it is just a wash.
- spread?: number (default 24) — total dispersion angle of the fan in
degrees, first band to last. Clamped to 0..180.
- bend?: number (default 20) — deviation between the incoming beam and the
centre of the fan, in degrees. Negative bends the other way.
- angle?: number (default 34) — heading of the incoming beam in degrees,
clockwise from "left to right" (canvas y points down).
- origin?: [number, number] (default [0.3, 0.16]) — where the beam meets the
prism edge, as a fraction of the box. Clamped to -0.25..1.25 so the apex
cannot be parked somewhere nothing it paints is visible.
- drift?: number (default 6) — peak angular swing of the drift in degrees.
- speed?: number (default 1) — multiplier on the drift clock; 0 freezes the
assembly and the rAF loop never starts.
- intensity?: "subtle" | "medium" | "bold" (default "medium") — per-fill
alpha tier; the one lever that protects overlaid copy.
- colors?: string[] (default ["chart-1"..."chart-5"]) — theme token names
WITHOUT the leading "--", cycled across the bands.
- phase?: number (default 0) — seconds injected into the drift clock. This is
what picks the still frame, and it is why nothing here ever reads a
render-time clock: Date.now() during render would make SSR and hydration
disagree.
- children render above the canvas; className merges onto the root via cn().
- "use client": canvas, rAF and observers.
- Clamp every numeric prop before use and treat non-finite values as the
default. NaN bands empties the fan, a 400-degree spread wraps the bands back
over the incoming beam, a negative speed runs the drift backwards.
- Derive scalars from the tuple/array props (originX, originY, and a
colors.join("|") palette key) and put THOSE in the effect deps, so a caller
who inlines an array literal does not re-run the effect on every render.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper
for children. The component paints NO background of its own: the surface
belongs to the consumer.
- Geometry, all in CSS px with the apex at origin * (width, height):
- reach = hypot(width, height) * 1.06, so a band leaving any apex inside the
box always exits the frame instead of ending in mid-air.
- spacing = spread / (bands - 1); band i is at
centre + (i/(bands-1) - 0.5) * spread, where centre = angle + bend.
- Each band is TWO wedges sharing an apex: a "core" whose angular width is
0.52 * spacing and a "glow" at 2.1 * spacing, both clamped to a 0.55..24
degree half-width so 2 bands and 12 bands still look like beams. The core
staying under one spacing is deliberate — the gap between cores is what
keeps the split readable when every token resolves to the same hue.
- A shared apex a couple of px wide (reach * 0.004, clamped 1.5..6) fuses the
bands into one hot spot at the prism instead of a bouquet of triangles.
- The unsplit beam is one more wedge drawn BACKWARDS from the apex (rotate by
angle + PI), widening slightly toward its off-screen source.
- The implied edge is a hairline rect through the apex, rotated to
angle + bend/2 + 90 degrees — perpendicular to the mean ray, so it reads as
the surface the light passes through without ever drawing a solid prism.
- Cached local space is the whole performance story. Every path is built once
with its apex at (0,0) and its axis along +x, and every gradient is defined in
that same space; a frame is then translate + rotate + fill per layer. Nothing
is recomputed or allocated while animating — rebuild only on resize and on
theme flips. A twelve-band fan is 26 fills of cached geometry, and widening
the fan costs area, not objects: the whole assembly covers roughly one
container of fill per frame.
- Gradients: each band ramp holds its colour to a fraction of the length and is
gone by another (core 0.34 -> 1, glow 0.08 -> 0.72, beam 0.2 -> 0.95, all
measured from the apex). The facet ramp is transparent -> ink -> transparent
so the edge dies at both ends. Two or three stops are enough because canvas
gradients interpolate in PREMULTIPLIED sRGBA — fading to the `transparent`
keyword never drags the tail toward grey — and that is also why no alpha
literal is needed anywhere: opacity lives in globalAlpha and in that keyword.
- Colour: read the palette as custom properties off the canvas's own computed
style (getPropertyValue("--<token>")) and the beam/facet ink from
getComputedStyle(canvas).color, i.e. currentColor — an inverted panel then
inverts the unsplit beam for free. Validate every string by round-tripping it
through ctx.fillStyle BEFORE handing it to addColorStop: fillStyle silently
keeps its old value on an unparsable string, but addColorStop throws a
SyntaxError, and a consumer token can hold anything. Start from
fillStyle = "transparent" so the readback means "did not resolve" instead of
the default opaque black, and fall back to the ink rather than dropping the
band.
- Compositing follows the SURFACE, not the theme class. Walk up from the canvas
to the first ancestor with an opaque background, resolve that colour through
a 1x1 scratch canvas (fillRect + getImageData — no hand parsing, so oklch()
and color-mix() work) and compute its relative luminance:
- below 0.35 it is dark enough for light: globalCompositeOperation =
"lighter", so overlapping bands sum toward a white core near the apex,
exactly like real dispersion.
- above it, the same sum would be white on white, i.e. invisible. Flip to
"multiply" and scale every alpha by ~0.62: overlaps now get DARKER and more
saturated, which is the same figure a physics textbook prints in ink on
paper. Ink reads about twice as heavy as light on black, hence the scale.
Both modes composite inside the transparent canvas, which then lands on the
page with plain source-over — so neither mode can bleed onto the surface.
Fall back to the `dark` class only if getImageData is unavailable.
- Colour separation must survive a monochrome palette, because it IS the
effect. Three independent channels carry it: the geometric gap between cores,
a static centre-bright weight ramp (0.68 + 0.32 * sin(PI * t), the shape of a
real spectrum), and a per-band shimmer offset by the golden angle so no two
bands brighten together. colors={["primary"]} still reads as a split fan.
- Motion: ONE sine swings the beam, the facet and the fan together — the prism
itself is what turns, so the geometry stays physically consistent — at
drift degrees peak, 0.13 rad/s. The dispersion angle breathes independently
(+/-8% at 0.19 rad/s) and each band shimmers +/-14% at 0.33 rad/s. All three
read the same simulated clock, which starts at `phase` and advances by
dt * speed.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it. Take max(devicePixelRatio, deviceBox/cssBox) capped
at 2: emulated surfaces report the device box in CSS px while rendering at
2x, and real hi-dpi windows have been measured reporting devicePixelRatio 1
with a truthful box, so neither source is trustworthy alone. Re-apply
ctx.setTransform after every resize (writing canvas.width resets the context)
and derive the scale from the real backing size. Skip the whole rebuild when
the box and the dpr are unchanged.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed and
speed > 0. dt is clamped to 1/30s so a backgrounded tab cannot fast-forward
the drift on resume, and the time base resets when the loop restarts.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
the tokens and repaints, then schedules ONE more read ~400ms later. Custom
properties are readable immediately, but the SURFACE is not: a panel with
transition-colors reports an intermediate colour for a few hundred ms, which
is long enough to latch the wrong additive/subtractive branch.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and keep it in the effect deps.
Under reduce the loop never starts and exactly one frame is painted at
`phase`: same beam, same facet, same full fan, with the shimmer phases
already spread apart. Never a blank box.
- Accessibility: the component is decoration and has no controls, so there is
no keyboard map and nothing to focus — the canvas is aria-hidden,
pointer-events-none and not focusable, so it never intercepts a click, never
takes a Tab stop and never appears to a screen reader. Children stay fully
interactive above it. There are no pointer handlers at all: the fan does not
track the cursor.
- Cleanup on unmount: cancelAnimationFrame, the ResizeObserver, the
IntersectionObserver, the MutationObserver, the settle timeout and the
visibilitychange listener.
Rendering & styling
- Semantic tokens only, zero colour literals: bands are var(--chart-1..5) (or
whatever token names the consumer passes), the beam and the facet are
currentColor, and every one of them is resolved through the canvas's own
computed style — so light/dark and any rebranded palette come for free.
Alpha never appears in a colour string; it lives in globalAlpha and in the
`transparent` gradient stop.
- Keep the alpha ceiling low enough that body copy on top stays readable: the
peak stacked opacity (glow + core + shimmer) stays under 0.7 even at "bold".
Overlaid copy should use text-foreground, not text-muted-foreground, which
has no headroom left for any wash.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes.
Customization levers
- Density: bands is the count knob (2 reads as a split beam, 5 as a spectrum,
12 as a fan) and spread is how far apart they land. CORE_WIDTH (0.52) and
GLOW_WIDTH (2.1), as multiples of the band spacing, decide whether the fan
reads as separate rays or as one soft body of light — raise the core toward 1
to close the gaps, drop the glow toward 1 to keep them crisp.
- Composition: angle is where the light comes from, bend how hard the prism
turns it, origin where the two meet. Negative angle/bend mirror the whole
assembly; putting origin outside 0..1 hides the apex off-frame and leaves
only the fan.
- Pace: speed scales the drift clock (0.3 is ambience, 0 is a still image),
drift is how far the assembly swings, and DRIFT_RATE / BREATHE_RATE /
SHIMMER_RATE are the three tempos underneath.
- Palette: colors takes token names, cycled — ["chart-1","chart-3","chart-5"]
for a three-hue spectrum, ["primary"] for a monochrome split, a brand token
for a corporate one. If you invert the panel, invert the palette too: the
theme's chart tokens are tuned for the theme's own surfaces.
- Readability: intensity is the tier meant for this; if you need finer control,
edit the four alphas in the tier (band / glow / beam / facet) rather than
scaling the whole canvas with opacity, which would flatten the compositing.
- Cost: fill-rate bound and linear in bands. MAX_DPR 2 is the other half of the
bill — drop it to 1 for very large full-bleed heroes and the fill cost halves
with almost no visible change on gradients this soft.
- Restraint: drop the facet hairline for a fan that appears from nowhere, or
drop the incident beam for pure colour rays — but keep at least one of them,
or the effect stops reading as refraction and becomes a plain gradient fan.Concepts
- Refraction fan — every band leaves the same apex at its own angle, so the effect reads as one beam being split rather than as several beams that happen to be near each other. The shared apex, the single incident shaft and the hairline facet are the three cues that sell it; remove all three and it collapses into a gradient.
- Separation before colour — the split is carried geometrically first: each core wedge is only about half the band spacing wide, the fan is brightest in the middle, and every band shimmers on its own golden-angle phase. That is why a monochrome palette (
colors={["primary"]}) still reads as dispersion instead of a smear. - Additive vs subtractive — the component probes the first opaque background behind it and picks
lighteron dark surfaces (overlaps sum toward a white core, like real dispersion) ormultiplyon light ones (overlaps darken, like ink on paper). It follows the surface, not the.darkclass, so an inverted panel inside a light theme still gets light. - Cached local space — paths and gradients are built once per resize with the apex at the origin and the axis along
+x; each frame istranslate + rotate + fill. Nothing is allocated while animating, which is what lets a twelve-band fan run at full-bleed size. - Injected phase — the drift clock starts at the
phaseprop, never at a render-timeDate.now(), so the server and the client agree on the first frame and any still frame is reproducible by value. - Reduced-motion still frame — under
prefers-reduced-motion: reduce(orspeed={0}) the loop never starts, but the single painted frame is the whole composition with the shimmer phases already spread apart, so the panel still looks lit rather than empty.
Ink
Drops of ink opening into water on one canvas — ragged fronts that expand, thin and dissolve, with every overlap deepening like ink on paper.
Galaxy
A spiral galaxy on one canvas — stars threaded onto logarithmic arms, a disc that rotates differentially, and a bright core, all painted in your theme tokens.