Ink Field
A living flow-field background of ink strands that drift, fade and curl around the pointer.
npx shadcn@latest add https://sundaeui.dev/r/ink-field.json'use client'
import { useEffect, useRef } from 'react'
export interface InkFieldProps {
/** Ink colours. The first is the main ink; later ones are progressively rarer accents. */
inks?: string[]
/** Paper colour behind the strands. Use 'transparent' to layer over content. */
paper?: string
/** Strands per 10,000 px² of canvas. */
density?: number
/** Global speed multiplier. */
speed?: number
/** 0–1. How quickly old strokes fade; lower keeps longer trails. */
fade?: number
/** Strands swirl around the pointer. */
interactive?: boolean
className?: string
style?: React.CSSProperties
children?: React.ReactNode
}
// Compact 2D gradient noise (Perlin-style) with a seeded permutation.
function makeNoise(seed = 7) {
const p = new Uint8Array(512)
const perm = Array.from({ length: 256 }, (_, i) => i)
let s = seed
for (let i = 255; i > 0; i--) {
s = (s * 16807) % 2147483647
const j = s % (i + 1)
;[perm[i], perm[j]] = [perm[j], perm[i]]
}
for (let i = 0; i < 512; i++) p[i] = perm[i & 255]
const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10)
const grad = (h: number, x: number, y: number) => ((h & 1 ? -x : x) + (h & 2 ? -y : y))
return (x: number, y: number) => {
const X = Math.floor(x) & 255
const Y = Math.floor(y) & 255
x -= Math.floor(x)
y -= Math.floor(y)
const u = fade(x)
const v = fade(y)
const a = p[X] + Y
const b = p[X + 1] + Y
const l1 = grad(p[a], x, y) + u * (grad(p[b], x - 1, y) - grad(p[a], x, y))
const l2 = grad(p[a + 1], x, y - 1) + u * (grad(p[b + 1], x - 1, y - 1) - grad(p[a + 1], x, y - 1))
return l1 + v * (l2 - l1)
}
}
/**
* A flow-field of ink strands. Renders to a single canvas, pauses when
* offscreen or hidden, and draws one settled still for reduced motion.
*/
export function InkField({
inks = ['#1f2a37', '#3d4f63', '#c2563a'],
paper = '#f4efe6',
density = 1.4,
speed = 1,
fade = 0.022,
interactive = true,
className,
style,
children,
}: InkFieldProps) {
const ref = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = ref.current
if (!canvas) return
const ctx = canvas.getContext('2d', { alpha: paper === 'transparent' })
if (!ctx) return
const noise = makeNoise(11)
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
let w = 0
let h = 0
let dpr = 1
let raf = 0
let running = false
let t = 0
const pointer = { x: -1e4, y: -1e4, active: false }
type P = { x: number; y: number; px: number; py: number; ink: string; life: number; maxLife: number; w: number }
let ps: P[] = []
const spawn = (p?: P): P => {
const q = p ?? ({} as P)
q.x = q.px = Math.random() * w
q.y = q.py = Math.random() * h
// Weighted: main ink ~70%, each later ink about a third as common as the one before.
const r = Math.random()
q.ink = inks.length === 1 ? inks[0] : r < 0.7 ? inks[0] : r < 0.93 || inks.length === 2 ? inks[1] : inks[2 + ((Math.random() * (inks.length - 2)) | 0)]
q.life = q.maxLife = 120 + Math.random() * 260
q.w = 0.35 + Math.random() * 0.8
return q
}
const paint = () => {
if (paper === 'transparent') ctx.clearRect(0, 0, w, h)
else {
ctx.fillStyle = paper
ctx.fillRect(0, 0, w, h)
}
}
const resize = () => {
const r = canvas.getBoundingClientRect()
dpr = Math.min(devicePixelRatio || 1, 2)
w = Math.max(1, r.width)
h = Math.max(1, r.height)
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const n = Math.min(1600, Math.round(((w * h) / 10000) * density * 10))
ps = Array.from({ length: n }, () => spawn())
paint()
}
const step = () => {
t += 0.0025 * speed
// Fade previous strokes toward the paper.
if (paper !== 'transparent') {
ctx.globalAlpha = fade
ctx.fillStyle = paper
ctx.fillRect(0, 0, w, h)
ctx.globalAlpha = 1
}
ctx.lineCap = 'round'
for (const p of ps) {
const n = noise(p.x * 0.0028, p.y * 0.0028 + t)
const a = n * Math.PI * 3.2
let vx = Math.cos(a)
let vy = Math.sin(a)
if (interactive && pointer.active) {
const dx = p.x - pointer.x
const dy = p.y - pointer.y
const d2 = dx * dx + dy * dy
if (d2 < 32000) {
const f = 1 - d2 / 32000
// Tangential swirl around the pointer.
vx += (-dy / Math.sqrt(d2 + 1)) * f * 2.2
vy += (dx / Math.sqrt(d2 + 1)) * f * 2.2
}
}
p.px = p.x
p.py = p.y
p.x += vx * 1.1 * speed
p.y += vy * 1.1 * speed
p.life--
ctx.strokeStyle = p.ink
ctx.globalAlpha = Math.min(1, p.life / 40, (p.maxLife - p.life) / 30) * 0.42
ctx.lineWidth = p.w
ctx.beginPath()
ctx.moveTo(p.px, p.py)
ctx.lineTo(p.x, p.y)
ctx.stroke()
if (p.life <= 0 || p.x < -10 || p.x > w + 10 || p.y < -10 || p.y > h + 10) spawn(p)
}
ctx.globalAlpha = 1
}
const loop = () => {
step()
raf = requestAnimationFrame(loop)
}
const start = () => {
if (running || reduce) return
running = true
raf = requestAnimationFrame(loop)
}
const stop = () => {
running = false
cancelAnimationFrame(raf)
}
resize()
if (reduce) for (let i = 0; i < 180; i++) step()
const ro = new ResizeObserver(() => {
resize()
if (reduce) for (let i = 0; i < 180; i++) step()
})
ro.observe(canvas)
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()))
io.observe(canvas)
const onVis = () => (document.hidden ? stop() : start())
document.addEventListener('visibilitychange', onVis)
const host = canvas.parentElement!
const move = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect()
pointer.x = e.clientX - r.left
pointer.y = e.clientY - r.top
pointer.active = true
}
const leave = () => (pointer.active = false)
if (interactive) {
host.addEventListener('pointermove', move)
host.addEventListener('pointerleave', leave)
}
return () => {
stop()
ro.disconnect()
io.disconnect()
document.removeEventListener('visibilitychange', onVis)
host.removeEventListener('pointermove', move)
host.removeEventListener('pointerleave', leave)
}
// Keyed on the ink values so a new array literal each render doesn't restart the field.
}, [inks.join('|'), paper, density, speed, fade, interactive]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className={className} style={{ position: 'relative', overflow: 'hidden', ...style }}>
<canvas ref={ref} aria-hidden="true" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />
{children && <div style={{ position: 'relative' }}>{children}</div>}
</div>
)
}Overview
Hundreds of short strokes follow a slowly shifting Perlin noise field across a paper-coloured canvas. Each frame fades the last one a little toward the paper, so the strands leave soft trails that read like ink spreading through water.
Move the pointer and the strands nearby bend into a gentle vortex. When the pointer leaves, they drift back into the field.
It is a single canvas with no dependencies. It is DPR-aware, sizes itself to its container, pauses when offscreen or when the tab is hidden, and renders a settled still frame for visitors who prefer reduced motion.
When to use
- Hero areas and empty states that need atmosphere behind real content.
When not to use
- Behind long-form text, where motion competes with reading.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/ink-field.jsonnpx shadcn@latest add https://sundaeui.dev/r/ink-field.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/ink-field.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/ink-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy ink-field.tsx into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { InkField } from '@/components/sundae/ink-field'
export function Hero() {
return (
<InkField className="min-h-[80vh]" inks={['#1f2a37', '#c2563a']} paper="#f4efe6">
<div className="mx-auto max-w-3xl px-6 py-32">
<h1>Slow water, carrying ink.</h1>
</div>
</InkField>
)
}Props
| Prop | Type | Default |
|---|---|---|
inksStroke colours. The first is the main ink (about 70% of strands), and later colours are rarer accents. | string[] | ['#1f2a37', '#3d4f63', '#c2563a'] |
paperBackground colour that strokes fade into. 'transparent' draws without trails. | string | '#f4efe6' |
densityStrand density relative to canvas area (capped at 1,600). | number | 1.4 |
speedGlobal speed multiplier. | number | 1 |
fadePer-frame fade toward the paper, from 0 to 1. Lower values leave longer trails. | number | 0.022 |
interactiveStrands swirl around the pointer. | boolean | true |
childrenContent rendered above the canvas. | ReactNode | — |
Accessibility
- The canvas is aria-hidden. It is decoration only, and content lives in normal DOM above it.
- With prefers-reduced-motion, it renders a single settled frame and never starts the animation loop.
- Check text contrast against your chosen paper and ink. The defaults give dark text roughly 12:1 against the paper.
Customization
- In dark mode, pass a dark paper such as #0f1115 and light inks for a chalk-on-slate look.
- Lower speed and fade, for example 0.5 and 0.02, give a calmer background behind long-form reading.
FAQ
- Is Ink Field free?
- Yes. Ink Field is free for personal and commercial projects, with full source on this page.
- How do I install Ink Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/ink-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Ink Field support keyboard and screen readers?
- The canvas is aria-hidden. It is decoration only, and content lives in normal DOM above it. With prefers-reduced-motion, it renders a single settled frame and never starts the animation loop.
- Does Ink Field respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All backgrounds →- Pro
Contour Field
Backgrounds · Editorial - Pro
Dot Grid
Backgrounds · Understated - Pro
Silk Shader
Backgrounds · Expressive Taffy Tabs
Navigation · Playful- Pro
Split-Flap Display
Motion · Brutalist - Pro
Tender Checkout
Checkout · Refined