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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/silk.jsonPrompt
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
seedalways 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 theirstop-color, so the browser resolves the theme at paint time. There is nogetComputedStyle, no colour parsing and noMutationObserverwatching 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 atspeed={0}, the loop never starts and the single composed frame is taken at a constantt = 11.7srather 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.
Vortex
A canvas vortex — particles spiral into one point, winding faster as they fall, trailing comet tails made by fading the buffer instead of storing a path.
Sonar
A radar sweep on one canvas — a rotating wedge with a decaying tail over concentric range rings, and contacts that flare when the beam passes and fade until the next one.