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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ink.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Ink" component — drops of ink opening
into water, painted on one canvas, used as a hero / empty-state / section
backdrop. Its only dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function Ink(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- variant?: "bloom" | "drops" | "wash" (default "bloom") — one pipeline,
three parameter sets: a few large slow fronts, many small quick ones, or a
couple of very large, very slow sheets.
- count?: number — blooms in flight. NO fixed default: it falls back to the
variant's own (bloom 7, drops 13, wash 4), because the right population is
a property of the character, not of the caller. Clamped to 1..24.
- density?: number (default 0.55) — peak ink opacity, clamped to 0..1. 1 is
the design ceiling; do NOT offer more, a background's job is to stay under
the text.
- speed?: number (default 1) — multiplier on growth, drift and rim creep. 0
freezes the field AND the rAF loop never starts.
- size?: number (default 1) — scales every final radius, clamped to 0.2..2.5.
- wobble?: number (default 1) — rim-noise amplitude, clamped to 0..2. 0 is a
supported look, not a degenerate one: clean circular fronts.
- tone?: "foreground" | "muted" | "primary" | "chart" (default "foreground")
— which semantic token the ink is painted with.
- blend?: "multiply" | "normal" (default "multiply").
- quality?: number (default 1) — backing store as a fraction of the device
pixels, clamped to 0.25..1. The honest cost knob: cost is quadratic in it.
- seed?: number (default 3) — integer seed for the field.
- children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and observers.
- Clamp every numeric prop before use and treat a non-finite value as the
default. NaN anywhere in the geometry poisons the transform and blanks the
canvas, count 1e6 freezes the tab, a negative size flips every bloom inside
out, quality 12 asks for a multi-megapixel backing store. An unknown variant
falls back to the default profile rather than throwing on a JS call site.
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. There is no keyboard surface and no pointer handling at all;
it is decoration, children own every interaction.
- One bloom = one object in a fixed pool, and the pool is the whole simulation.
Each carries centre, drift velocity, birth time, life, final radius, three rim
harmonics (lobe count, phase, creep rate, amplitude each), a slight ellipse
with its rotation, and a peak alpha. Nothing is allocated inside the frame
loop — not the pool, not the rim buffer, not the gradient. Per-frame
allocation is THE failure mode of a full-bleed effect.
- Deterministic field: every one of those properties is an integer hash of
(dropId, salt), where dropId mixes the seed with the drop's index and the salt
mixes in the drop's own recycle counter. Address blooms by index rather than
pulling from a PRNG stream: growing the pool after a resize then never
reshuffles the ones already on screen. Math.random() is never called — not
during render (purity/SSR) and not in the loop (screenshots must be
reproducible). Same seed, same ink.
- Life cycle. t = (elapsed - born) / life in 0..1.
- Growth is an exponential approach: r = maxR * (1 - e^(-k*t)) / (1 - e^(-k)),
k = 2.0..3.2 per variant. A fast initial rush then a long settle — the shape
of a real drop opening in still water. Linear growth reads as a machine.
- Thinning conserves mass: alpha /= 1 + thin * g², g = r / maxR. A young front
is a dense dot; a fully spread one is roughly a quarter as opaque. This is
what makes it read as diffusion instead of as a growing disc.
- Two envelopes keep birth and death off the screen: smoothstep fade-in over
the first 12% of the life, smoothstep fade-out over the last 22%.
- Past t = 1 the bloom is REBORN in place: bump its cycle counter, re-hash
every property, set born = now. The pool size never changes and no object
is ever collected.
- The field settles instead of looping. Lives are per-drop and mutually
incommensurate (profile life x 0.65..1.45), every rebirth re-randomises
position, life, radius and rim, and — the part that matters on the FIRST
frame — each bloom is seeded at a hashed fraction of its own life (0.02..0.98)
rather than at 0. Without that stagger the whole field would open in unison
once and then never agree again; with it, frame 1 already shows every stage at
once and there is no global period at all.
- The rim is a radial harmonic series, not a circle:
r(theta) = 1 + amp * SUM_k a_k * sin(h_k * theta + p_k + w_k * elapsed), with
three harmonics whose lobe counts h_k are WHOLE numbers drawn from ~3..5,
6..9 and 11..16 — whole so the curve closes on itself exactly, and
incommensurate so the silhouette, the fingers and the fray never line up.
Amplitudes are ~0.105 / 0.06 / 0.032 times the profile's wobble times the prop.
amp = 0.55 + 0.65 * t, so fingers stretch as the front spreads. Because r
never reaches 0 the outline stays star-shaped: it can crumple, it can never
self-cross, so the fill rule never matters. The w_k are signed and small
(up to 0.25 / 0.35 / 0.5 rad/s) so the edge crawls rather than shimmers; take
each phase modulo 2pi at use, so hours of uptime cannot degrade the sine.
- Tracing: sample the series at 64 angles into two preallocated Float64Arrays
(cos/sin of those angles are a module-level table — the circle never changes,
only the radius does), then draw quadratics through the MIDPOINTS of
consecutive samples. Midpoint quadratics turn 64 points into a C1 curve; a
64-gon at hero size shows its chords.
- ONE gradient for the whole component, in UNIT space. Build a radial gradient
of radius 1.22 once (and again on a theme flip), then per bloom
save / translate(x,y) / rotate / scale(radius*sx, radius*sy) / fill / restore.
Canvas gradients are transformed by the CTM at paint time, so that single
object serves every bloom at every size — rebuilding a gradient per bloom per
frame is the allocation that kills this effect. Stops (offset, alpha):
0/1, 0.30/0.96, 0.60/0.72, 0.80/0.38, 0.92/0.14, 1/0. The rim swings roughly
0.7..1.35 at the default wobble, i.e. it straddles the gradient's end: the
deeper bites read as a defined front, the outward fingers dissolve into
nothing, and pushing wobble to its ceiling simply frays more of the edge away
instead of producing an artefact — that contrast IS the ink look.
Guard: scale(0) makes the CTM non-invertible and silently drops the fill, so
skip any bloom under ~0.5px.
- Partial-alpha stops need color-mix(in oklab, <ink> N%, transparent) — which
never parses the ink, it only wraps it. Wrap the whole build in try/catch and
fall back to a plain ink -> transparent two-stop ramp: a softer core, the same
silhouette. Canvas gradients interpolate premultiplied, so fading to
"transparent" does not drag the ink through black.
- Overlap. globalCompositeOperation = "multiply" is what makes two crossing
fronts DARKEN each other instead of merely stacking alpha; on an empty
destination multiply is identical to source-over, so the first bloom is
unaffected and nothing washes out. It works in both schemes: with a near-black
ink on a light surface the crossing goes visibly darker, and with a near-white
ink on a dark one the alpha gain dominates so it still reads as more ink.
blend="normal" (source-over) is the escape hatch when you want flat
accumulation with no hue movement. Alpha always rides on globalAlpha, never in
the colour string.
- Ink colour: the canvas carries the tone token as an inline `color`; read
getComputedStyle(canvas).color back and hand that string to the gradient
verbatim. Never hand-parse a colour; passing the computed string through means
oklch(), color-mix() and any rebranded palette all work. Each tone carries a
loudness trim (foreground 0.62, muted 1, primary 0.82, chart 0.88) because
--foreground is near-black on light and near-white on dark, so the same alpha
reads about twice as loud with it as with a muted token.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing.
Backing store = css size * min(devicePixelRatio, 2) * quality, additionally
capped so the LONG EDGE never exceeds 1600 device px — one factor for both
axes so the aspect ratio survives. A field of soft gradients carries no detail
past that, and the browser's own filtering supplies the upscale, which for ink
is free blur rather than lost quality; that cap is what makes a full-bleed
hero cost about what a card does. Re-apply ctx.setTransform after every resize
(writing canvas.width resets the context) and derive the scale from the actual
backing size.
- Radii come from sqrt(width * height), the geometric mean, not from the shorter
edge: a 1440x200 banner and a 600x600 panel then get blooms of comparable
weight instead of a strip of dots. On resize, existing blooms are rescaled in
place (positions by each axis, radii by the ratio of the means) — a resize, or
a sidebar opening, must not restart the field.
- 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 teleport the
field on resume, and the time base resets when the loop restarts. A frozen or
off-screen field still holds its painted frame — pausing must never blank the
canvas.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
the ink, rebuilds the gradient carrying it, and repaints the still frame when
the loop is paused — then schedules ONE more read ~400ms later. A surface
animated with transition-colors reports an intermediate colour for a few
hundred ms, and since nothing else ever re-reads the token, a single read
would leave the field painted in that in-between colour for the session.
- 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. Because births
are already staggered across the lives, that frame is a full composition —
blooms at every stage, overlapping, at their own rim phases — not a blank box
and not a ring of identical dots.
- Cleanup on unmount: cancelAnimationFrame, the settle timeout, the
ResizeObserver, the IntersectionObserver, the MutationObserver and the
visibilitychange listener. Everything is rebuilt when a dependency changes, so
the same teardown covers both paths.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
var(--muted-foreground) / var(--primary) / var(--chart-1) resolved through the
canvas's own computed style, so light/dark and any rebranded palette come for
free.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes. Sizing, rounding and the surface come from the call site.
- Accessibility: the canvas is aria-hidden and pointer-events-none, pure
decoration that never traps scroll, focus or clicks; children stay fully
interactive above it. Overlaid copy should use text-foreground — the muted
token has no contrast headroom left to spend on a wash.
Customization levers
- Character: PROFILES is the whole vocabulary — count, radius fraction, life,
alpha ceiling, wobble, drift, rise, thinning strength and growth k. A fourth
variant is one more row, not new code: "drip" = drops' size with wash's life
and a large rise; "stain" = bloom's size with growth k near 1 and thin near 0,
which stops the front thinning and gives flat paper stains.
- Cost: count is linear and quality is quadratic; the 1600px long-edge cap is
the safety valve, and MAX_DPR 1 halves the fill again on retina. Fill area is
the real bill, so `size` is a cost knob too — doubling it quadruples overdraw.
64 rim segments and 24 blooms are the two hard ceilings.
- Shape: the three harmonic bands (lobe counts and amplitudes) decide whether
the edge reads as lopsided, fingered or frayed; raise the top band's amplitude
for fibrous paper bleed, drop wobble to 0 for clean geometric fronts.
- Motion: rise turns a bloom into a plume that climbs as it opens; negate it for
ink sinking. The w_k phase rates control the crawl of the edge — equal rates
make the rim read as one rigid wobble, so keep them incommensurate.
- Depth of field: the gradient stops are the profile of a single bloom. Pushing
the 0.60 stop up gives a hard-edged blot; pulling the whole ramp down gives a
diffuse cloud with no visible front.
- Palette: add a tone entry pointing at any token — var(--primary-foreground) is
the right ink on an inverted panel, var(--chart-3) for a branded wash. Give
every new tone a trim so density keeps meaning one thickness.
- Layering: it is a container, so nest it — a wash behind a whole hero and a few
drops inside one card, or a grain layer on top of it for a papery finish.Concepts
- Mass-conserving thinning — a front's opacity is divided by
1 + thin·g², wheregis how far it has spread toward its final radius. Ink that covers four times the area is four times weaker, so a bloom reads as diffusing rather than as a disc that happens to be growing; it is also whydensitycan stay low and the field still has a dense, legible core at every birth. - Radial harmonic rim — the outline is
1 +three sine harmonics of the angle with whole, mutually incommensurate lobe counts. Whole numbers make the curve close on itself exactly; incommensurate ones stop the silhouette, the fingers and the fray from ever lining up. Because the radius never reaches zero the shape stays star-shaped: it can crumple, it can never cross itself. - Staggered births — each bloom starts life at a hashed fraction of its own duration instead of at zero, and every rebirth re-hashes its position, life, radius and rim. The first painted frame is therefore already a composition, and the field has no global period to notice — it settles instead of looping.
- One gradient in unit space — a single radial gradient of radius 1.22 is built once and scaled by the CTM for every bloom. Canvas gradients are transformed at paint time, so this replaces one allocation per bloom per frame with zero; the rim's lobes deliberately reach past the gradient's end, which is what gives a defined front that frays into nothing.
- Multiply as the overlap rule — on an empty destination
multiplyis identical tosource-over, so the first front is untouched, but every crossing darkens instead of merely stacking alpha. That is the difference between "two translucent shapes" and "ink pooling on paper", andblend="normal"is the escape hatch when flat accumulation is what you want. - Resolution as the cost knob — the backing store is
devicePixelRatio(capped at 2) timesquality, with an absolute 1600px long-edge cap. Soft gradients carry no detail past that, so the browser's upscale is free blur; overdraw, not pixel count, is what makes a full-bleed ink field expensive, which is whycountandsizeare cost knobs as much as looks knobs.
Nebula
A layered interstellar cloud on canvas — token-tinted noise octaves drifting at different rates under a sparse star layer, with depth from parallax.
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.