Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Alpha tier for the whole assembly — the lever that keeps overlaid copy legible. */
export type PrismIntensity = "subtle" | "medium" | "bold"

const DEG = Math.PI / 180
/** A backgrounded tab resumes with a huge gap — clamp so the fan never jumps. */
const MAX_DT = 1 / 30
/** 3x backing stores cost 2.25x the fill for no visible gain on a soft gradient. */
const MAX_DPR = 2
/** Two rays is the least that can read as "split"; past twelve the gaps close and it is a wash. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prism.json

Prompt

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 lighter on dark surfaces (overlaps sum toward a white core, like real dispersion) or multiply on light ones (overlaps darken, like ink on paper). It follows the surface, not the .dark class, 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 is translate + 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 phase prop, never at a render-time Date.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 (or speed={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.

On This Page