Backgrounds

Silk

Flowing bands of light on one inline SVG — stacked sine ribbons, each a soft gradient between two chart tokens, drifting slowly enough to read as fabric.

Preview in your theme

Loading preview…

"use client"

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

/** Peak stop alpha per tier — the single knob for how loud the weave is. */
const PEAK_ALPHA = {
  subtle: 0.14,
  medium: 0.26,
  bold: 0.42,
} as const

export type SilkIntensity = keyof typeof PEAK_ALPHA

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Silk" component — stacked sine ribbons
drifting behind content like light on fabric, drawn as ONE inline SVG whose
path data is re-sampled every frame. Its only dependency is a cn() class
merger (clsx + tailwind-merge).

Contract
- export function Silk(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root, so ref, id and data-* ride along) plus:
  - bands?: number (default 5) — ribbons, rounded and clamped to 1..12.
  - speed?: number (default 1) — multiplier on the whole flow, clamped 0..8.
    0 composes one static frame and never starts the loop.
  - amplitude?: number (default 1) — multiplier on how far a ribbon swings
    away from its baseline, clamped 0..3.
  - thickness?: number (default 1) — multiplier on ribbon width, clamped
    0.1..3. Past ~1.6 neighbours merge into a wash.
  - tilt?: number (default 0.18) — drape: how far the weave falls across the
    container as a fraction of its height, clamped -0.6..0.6. Negative rises.
  - softness?: number (default 0.2) — edge blur as a FRACTION OF RIBBON WIDTH,
    clamped 0..0.6. Not a pixel radius: see Behavior.
  - intensity?: "subtle" | "medium" | "bold" (default "medium") — peak stop
    alpha 0.14 / 0.26 / 0.42.
  - colors?: string[] (default ["chart-1" ... "chart-5"]) — theme token names
    WITHOUT the leading "--", cycled across the ribbons. Ribbon i fades from
    colors[i] into colors[i+1], so a one-entry array is a monochrome weave.
  - seed?: number (default 5) — integer seed for the drape.
  - children render above the weave; className merges onto the root via cn().
- "use client": refs, rAF, ResizeObserver, IntersectionObserver, matchMedia.
- Clamp every numeric prop up front and treat a non-finite value as the
  default. 400 bands would build 400 paths per frame, a negative thickness
  would fold every ribbon inside out, and one NaN would erase the cloth.

Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) an aria-hidden,
  pointer-events-none stage div "absolute inset-0 overflow-hidden" that
  contains the svg, and (b) a "relative z-10" wrapper for children. Content
  therefore always sits above the weave, and the weave can never intercept a
  click, take focus or trap scroll. The component paints NO background of its
  own: the surface stays the consumer's, and the ribbons are translucent light
  over it.
- Band table: the weave is described ONCE per (bands, seed) as fractions of
  the container, never as pixels — a resize then rescales it for free, with no
  reseeding and no fix-up pass. Every field is an integer hash of
  (bandId, salt), never Math.random: not during render (purity and SSR) and not
  in the loop (screenshots must be reproducible). For ribbon i of n:
  - slot = 1.5 / n; baseline = -0.25 + (i + 0.5) * slot, i.e. the CENTRE of its
    slot, so the outer ribbons hang half off-frame at any count. Centring
    matters: putting baselines at the ENDS of a -0.25..1.25 span pushes both
    ribbons of a 2-band weave entirely outside the visible box.
  - half width = min(0.22, slot * 0.48) * (0.82..1.18). Past ~0.6 of the slot
    neighbours touch and the stack blurs into one wash indistinguishable from a
    mesh gradient; 0.48 leaves roughly a quarter of the height as bare surface,
    which is what makes the ribbons read as ribbons at all.
  - amplitude 0.045..0.08 of the height; wave counts across the width
    f1 = 0.7..1.3, f2 = 1.6..2.8, f3 = 0.5..1.3; phases anywhere in 0..2pi;
    angular rates w1 = 0.10..0.19, w2 = 0.06..0.13, w3 = 0.04..0.09 rad/s.
- Geometry per frame, per ribbon, sampled every ~20 CSS px (16..120 samples
  across the domain), with w and h the VISIBLE box:
  centre(x) = baseline*h + (x - midX) * (tilt*h/w)
            + amp * ( sin(k1*x + p1 + t*w1) + 0.45 * sin(k2*x + p2 - t*w2) )
  halfWidth(x) = half * ( 0.78 + 0.22 * sin(k3*x + p3 + t*w3) )
  with k = 2*pi*f / w. Four things there are load-bearing:
  - the second harmonic COUNTER-travels (minus t*w2). Two co-travelling sines
    look like one wave; the beat between opposed ones is what stops a slow loop
    from reading as a repeat.
  - time enters only as a phase shift, so the pattern travels at w/k px/s,
    which is proportional to the container width: a small card and a wide hero
    take the same number of SECONDS per fold.
  - every ribbon runs at its own rates, which is what keeps the stack from
    marching in lockstep like a barcode.
  - halfWidth breathes along the ribbon between 0.56 and 1 of nominal. That is
    a fold catching light; a constant width reads as a ruled stripe. The floor
    is positive, so the two edges can never cross.
- Path building: walk the top edge left to right, the bottom edge right to
  left, then close. Evaluate the sines ONCE per sample into two pooled
  Float64Arrays that both edges read, and write the path tokens into one pooled
  array sized exactly (samples+1)*8 + 1 so join("") never sees a stale tail.
  Coordinates are quantised to 0.1px: short strings, still far finer than a
  blurred edge can resolve. One d-attribute write per ribbon per frame, no
  React state, nothing allocated in the loop. A full-bleed 1400x700 weave
  measures ~0.18ms of JS per frame at 5 bands and ~0.30ms at 12 — band count is
  the linear cost knob, and the blur is composited on the GPU.
- Colour: each ribbon is filled with its own linearGradient in
  objectBoundingBox units, diagonal (x1=0,y1=0 to x2=1,y2=1) with the y ends
  swapped on alternate ribbons so neighbours catch the light from opposite
  edges. Four stops: colors[i] at 0.30*peak, colors[i] at 0.85*peak at
  (highlight - 0.22), colors[i+1] at peak at (highlight + 0.25), colors[i+1] at
  0.30*peak at 1 — where highlight is 0.3..0.7 per ribbon. The ends stay LIT
  rather than fading to zero: a ribbon that vanished at both ends would leave
  the container's left and right edges visibly empty. Because the gradient is
  anchored to the ribbon's box while the folds travel underneath it, the
  highlight behaves like a fixed light source on moving cloth. The stops carry
  style={{ stopColor: "var(--chart-2)" }} — the token itself. SVG resolves it
  through CSS, so a theme flip repaints the weave with no getComputedStyle, no
  MutationObserver and no re-render. That is the one thing a canvas cannot do,
  and the reason this effect is SVG rather than 2D context.
- Sizing and softness: a ResizeObserver observes the STAGE, never the svg —
  the effect resizes the svg, which would feed straight back into the observer.
  On each measurement:
  blur = min(64, softness * meanRibbonHalfWidth * height * thickness). Because
  the blur is measured against the RIBBON and not against the viewport, a 180px
  card and a 900px hero get the same LOOK rather than the same radius. The blur
  bleeds transparency inward from the svg's own edges, so the drawing domain is
  the visible box inflated by pad = ceil(2 * blur) on every side, which puts
  the fade outside the visible box instead of vignetting it. Write viewBox,
  left/top and width/height on the svg explicitly — an absolutely positioned
  replaced element with insets alone falls back to its intrinsic 300x150. Band
  geometry is derived from the VISIBLE box, so changing softness never moves a
  ribbon. Re-sample buffers only when the sample count actually changes.
- Power: the rAF loop runs only when an IntersectionObserver says the stage is
  on screen, document.visibilityState is "visible", speed > 0 and motion is
  allowed. dt is clamped to 1/30s so a backgrounded tab cannot fast-forward the
  cloth on resume, and the time base resets when the loop restarts. Simulated
  time lives in a ref rather than inside the effect, so changing speed or
  softness re-runs the effect without teleporting the cloth.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
  snapshot false, so it is hydration-safe) and kept in the effect deps. Under
  reduce the loop never starts and exactly one frame is composed. That frame is
  taken at a CONSTANT t = 11.7s, never a clock read: the ribbons are already
  out of phase with each other, server and client agree, and repeated
  screenshots are identical. speed={0} takes the same path.
- Cleanup on unmount and on every dependency change: cancelAnimationFrame, both
  observers disconnected, and the visibilitychange listener removed.

Rendering & styling
- Semantic tokens only, zero colour literals: gradient stops are
  var(--chart-1..5) — or whatever token names the consumer passes — and alpha
  lives in stop-opacity. Light, dark and any rebranded palette come for free.
- cn() merges the consumer className onto the root; the stage and the svg keep
  their own classes, and the svg's position, size and filter are written by the
  effect, so React never manages that element's style.
- Accessibility: the weave is decoration — aria-hidden and pointer-events-none.
  Nothing inside it is focusable or hit-testable, so there is no keyboard map
  and no ARIA role to assign; all interactivity belongs to children, which stay
  above it, selectable and focusable. Copy on top of any background belongs in
  text-foreground, and intensity should drop to "subtle" when text sits
  directly on the ribbons — the chart tokens differ in lightness between light
  and dark, so check both.

Customization levers
- Density: bands is the main dial and the linear cost knob (1..12). Ribbon
  width follows the slot, so raising the count alone thins the ribbons instead
  of closing the weave; push them back together with thickness.
- Motion: speed scales everything at once. The per-ribbon rates w1/w2/w3 are
  the tempo spread — collapse them to one constant for a rigid marching weave,
  widen them for a lazier, more organic drift. The 0.45 harmonic weight decides
  how wavy versus how straight the ribbons look.
- Shape: tilt is the drape (0 for horizontal bands, ±0.6 for a steep sash),
  amplitude is the swing, and the 0.78 width floor is how hard the folds pulse.
  The 0.48 slot factor is the single number that decides ribbons versus wash.
- Softness: 0 for crisp vector bands, 0.2 for silk, 0.6 for a mesh-gradient
  wash. It is relative, so it survives any container size; the 2x pad exists
  only to keep its fade off screen.
- Palette: colors takes token names — ["primary"] for a monochrome weave,
  ["chart-2", "chart-4"] for a brand pair, the full chart ramp for a rainbow.
  intensity is the loudness tier; add a tier if 0.42 is still too quiet on your
  surface. Nothing here parses a colour, so any token value works.
- Composition: the root is a plain container, so put a hero, a form or a
  pricing header inside it. To use it as a pure overlay instead, drop the
  children wrapper and give the root "absolute inset-0" at the call site.

Concepts

  • Band table in fractions — every ribbon is described once, as fractions of the container rather than pixels: baseline, amplitude, half width, wave counts across the width, phases and tempos. A resize therefore rescales the whole weave by multiplying, with no reseeding and no per-ribbon fix-up, and the same seed always drapes the same way because each field is an integer hash of (bandId, salt) instead of a draw from a random stream.
  • Per-ribbon tempo — each ribbon carries its own angular rates, and its second harmonic counter-travels against its first. Ribbons sharing one rate march in lockstep and read as a barcode; the beat between opposed harmonics is what keeps a minute-long loop from reading as a repeat.
  • Breathing half width — a ribbon's width oscillates along its own length between 0.56 and 1 of nominal, so the band narrows where it turns away and widens where it faces you. That oscillation is the difference between a fold of cloth and a ruled stripe, and its positive floor is what stops the two edges from crossing.
  • Softness relative to the ribbon, not the viewport — the blur radius is a fraction of the mean ribbon width times the measured height, so a 180px card and a 900px hero get the same look instead of the same radius. The drawing domain is inflated by twice the blur on every side, which puts the blur's own fade outside the visible box rather than vignetting it.
  • Token colour with no colour read — the gradient stops carry var(--chart-n) as their stop-color, so the browser resolves the theme at paint time. There is no getComputedStyle, no colour parsing and no MutationObserver watching for a theme flip — the one thing a canvas effect cannot do, and the reason this one is SVG.
  • Still frame at a non-zero phase — under prefers-reduced-motion, or at speed={0}, the loop never starts and the single composed frame is taken at a constant t = 11.7s rather than at 0, so the ribbons are already out of phase and it still reads as cloth. Because that constant is not a clock read, SSR and hydration agree and repeated screenshots are identical.

On This Page