Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

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

/** `bloom` = a few large slow fronts, `drops` = many small quick ones, `wash` = a couple of very large, very slow sheets. */
export type InkVariant = "bloom" | "drops" | "wash"

export type InkTone = "foreground" | "muted" | "primary" | "chart"

/** `multiply` deepens every overlap the way ink pools on paper; `normal` only accumulates alpha. */
export type InkBlend = "multiply" | "normal"

/**

Installation

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

Prompt

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², where g is 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 why density can 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 multiply is identical to source-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", and blend="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) times quality, 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 why count and size are cost knobs as much as looks knobs.

On This Page