Twenty-eight brush and ink effects for React, listed in the table below. Each one is a WebGL shader. Most play once as they scroll into view; inkTransition and kasumiTransition play each time they are called, Hinoko, Kaen, Shuchusen and Komorebi run while they are on screen, Fude is a brush the visitor draws with, InkFlick and Nijimi follow the pointer, and SenkoHanabi burns down as a load runs. React is the only dependency, and the core works without it.
Live demos: sumi.tengubyte.io/effects. Docs, one page per effect: sumi.tengubyte.io/docs
Sumi (墨) is the ink of Japanese brush painting. Made by TenguByte.
| Effect | What it does | Demo |
|---|---|---|
| BrushReveal | Paints brush art in the way the stroke was laid | ring |
| BrushPaint | Writes an SVG's strokes with a brush | kanji |
| BrushMark | Paints a mark round words | marks |
| Hanko | Presses a seal | seal |
| KanjiStrike | Strikes one character in a single brush sweep | strike |
| Enso | Draws an ensō from a seed | ensō |
| Fude | A sumi brush the visitor draws with | draw |
| Suminagashi | Floats ink on water | water |
| InkDrop | Drops ink into still water, or onto paper seen from above | drop |
| InkWipe | Brings an image in under ink | image |
| inkTransition | Changes the page under ink | page |
| TornEdge | Tears paper round a picture or across a section | tear |
| Irodori | Colours a picture in from wet blooms | colour |
| HakeCut | Brushes a picture in with a wide flat brush, or drags one stroke | hake |
| Tachigami | Cuts a picture or a word with one slash | slash |
| Kintsugi | Breaks a picture and mends it with gold | mend |
| Kasumi | Lays gold cloud bands across a section or the page | mist |
| Sansui | Paints ink-wash mountains | hills |
| Shibori | Dyes cloth in indigo, dip by dip | dye |
| Komorebi | Lets sunlight through swaying leaves, and the pointer moves the sun | leaves |
| InkSplat | Throws an ink splat | splat |
| Hinoko | Sends up embers | embers |
| Kaen | Sets fire along an edge, round a character or round a picture | fire |
| Shuchusen | Draws manga focus lines or speed lines | lines |
| Hanasaka | Brings a cherry branch into bloom | bloom |
| InkFlick | Flicks ink from a click | flick |
| Nijimi | Bleeds ink into the paper after the pointer | trail |
| SenkoHanabi | Burns a sparkler down as a load runs | sparkler |
The core calls for pages without React are under Without React.
npm install github:tengubyte/sumi#v0.14.0Each effect is a React component. Import it, give it a size with className or style, and it plays once it scrolls into view.
import { InkDrop } from "@tengubyte/sumi";
export function Tea() {
return <InkDrop color="#4f7a3a" style={{ width: "100%", aspectRatio: "4 / 3" }} />;
}Canvas effects such as InkDrop, Sansui and Enso fill the box you give them, so give them a width and a height or an aspect ratio. Picture effects such as InkWipe, TornEdge and Irodori take the picture's src, width, height and alt, and show the plain picture where WebGL is missing. Each effect's section lists its props. Pages without React can call the same effects from @tengubyte/sumi/core, as shown under Without React.
import { BrushReveal } from "@tengubyte/sumi";
// A ring, painted clockwise from 3 o'clock.
<BrushReveal src="/enso.webp" width={560} height={560} sweep="ring" />
// A band, painted left to right and stretched to 56 px tall.
<BrushReveal src="/band.webp" width={1342} height={130} style={{ height: 56 }} />The art must be on a transparent background, and its stroke must travel the way the sweep does:
sweep="line"(default): left to right.sweep="ring": clockwise round the centre of the image. SetstartAngleto where the stroke starts, in degrees clockwise from 3 o'clock.
| Prop | Type | Default | What it does |
|---|---|---|---|
src, width, height |
required | The art and its own size in px. | |
sweep |
"line" | "ring" |
"line" |
Left to right, or clockwise round the centre. |
startAngle |
number |
0 |
Ring only. Where the stroke starts, in degrees clockwise from 3 o'clock. |
duration |
number |
1.4 |
Seconds the stroke takes. |
delay |
number |
0 |
Seconds after it is in view. |
ease |
(t: number) => number |
joHaKyu |
Pass a stable function. |
alt, className, style |
Width alone keeps the art's shape; width and height stretch it. |
import { BrushPaint } from "@tengubyte/sumi";
import ei from "./06c38.svg?raw"; // 永 from KanjiVG
<BrushPaint svg={ei} label="永" style={{ width: 360, aspectRatio: "1" }} />Paints every path, line, polyline, polygon, circle, ellipse and rect in the SVG, in document order, as one stroke along its centre line. Fills are ignored. The brush is drawn in code. It lands and presses, runs lighter through the middle, shoulders into each sharp turn and dries out toward the end.
How a stroke leaves the paper comes from KanjiVG's kvg:type on each stroke. In any other SVG, set data-brush-end on a shape to tome (press and stop, the default), harai (sweep out to a point), hane (flick off a hook), na (widen, then sweep out), rise or dot.
KanjiVG's stroke data is CC BY-SA 3.0 (Ulrich Apel, kanjivg.tagaini.net), so it is not bundled. Fetch or import the characters you need and credit KanjiVG.
| Prop | Type | Default | What it does |
|---|---|---|---|
svg |
string |
required | SVG markup made of strokes. |
label |
string |
What the art says. Without it the canvas is hidden from assistive technology. | |
weight |
number |
0.034 |
Brush radius as a share of the art's width. |
speed |
number |
1 |
2 paints twice as fast. |
color |
`#${string}` |
"#161314" |
The ink. |
bleed |
number |
0.07 |
Ink that bleeds into the paper past the edges, 0 to 0.3. |
contrast |
number |
0.8 |
How much thinner horizontals are than verticals, 0 to 1. |
bristles |
number |
22 |
Bristle lines across the brush. |
delay |
number |
0 |
Seconds after it is in view. |
className, style |
Size the canvas here; the art sits centred without stretching. |
import { BrushMark } from "@tengubyte/sumi";
<h1>
Paint the <BrushMark type="circle">word</BrushMark> that <BrushMark delay={1}>matters</BrushMark>
</h1>Hand-drawn annotations in the spirit of Rough Notation, with the brush: underline (default), circle (an ensō round the word), highlight (a dry swipe behind the text), strike and box. The words stay plain text on one line; the mark is measured when it scrolls into view and painted on a canvas round it.
| Prop | Type | Default | What it does |
|---|---|---|---|
children |
ReactNode |
required | The words. They stay on one line. |
type |
"underline" | "circle" | "highlight" | "strike" | "box" |
"underline" |
The mark. A highlight goes behind the text. |
seed |
number |
1 |
How far off true the hand is. |
color |
`#${string}` |
"#d8001a" |
The ink. |
speed, delay |
number |
1, 0 |
2 paints twice as fast; seconds after it is in view. Stagger several marks with delay. |
import { Hanko } from "@tengubyte/sumi";
<Hanko text="天狗" shape="tall" white font='"Yuji Syuku"' style={{ width: 100, aspectRatio: "1 / 1.6" }} />A seal pressed in vermilion paste. It comes down, is rocked once round its face and lifts; the paste only reaches the paper's high points, so the impression keeps white specks, one side comes out lighter and the carved border has a few chips. Mount it when a form is sent to stamp the send.
| Prop | Type | Default | What it does |
|---|---|---|---|
text |
string |
required | One to four characters, read in columns from the right, each top to bottom. |
label |
string |
What the seal says. Without it the canvas is hidden from assistive technology. | |
shape |
"square" | "tall" | "round" |
"square" |
Size the canvas to match: square, or about 1:1.6 for tall. |
white |
boolean |
false |
White characters cut into a red block (hakubun) instead of red characters in a border (shubun). |
font, weight |
string, number |
a Mincho stack, 700 |
A CSS font family you load. The seal waits for it, and stretches the characters to fill the face. |
color |
`#${string}` |
"#d8001a" |
The seal paste. |
seed |
number |
1 |
Paper grain, chips in the border and which side pressed harder. |
speed, delay |
number |
1, 0 |
It takes 1.6 seconds at speed 1. |
The characters come from the font you pass, so sumi ships no glyph data.
import { KanjiStrike } from "@tengubyte/sumi";
<KanjiStrike text="死" label="Death" font='"Yuji Syuku"' glow style={{ width: 320, aspectRatio: "1" }} />One character in one sweep of the brush. The front of the sweep is streaky with bristles, the brush runs dry toward the end, and drops fly off where it stops. It lands a little large and shakes, then the ink creeps a short way into the paper. It suits alerts, error pages and title cards.
| Prop | Type | Default | What it does |
|---|---|---|---|
text |
string |
required | One character, or two. |
label |
string |
What it means. Without it the canvas is hidden from assistive technology. | |
font, weight |
string, number |
a Mincho stack, 700 |
A CSS font family you load. The strike waits for it. A brush face such as Yuji Syuku suits it. |
color |
`#${string}` |
"#d8001a" |
The ink. |
angle |
number |
-20 |
Direction of the sweep, degrees clockwise from pointing right. |
glow |
boolean |
false |
A red halo, for dark pages. |
seed |
number |
1 |
Changes the streaks and the drops. |
speed, delay |
number |
1, 0 |
It takes 1.8 seconds at speed 1. |
className, style |
Size the canvas here; the character takes 92% of its shorter side. |
import { Enso } from "@tengubyte/sumi";
<Enso seed={42} style={{ width: 360, aspectRatio: "1" }} />The same brush given one stroke made from a seed: where it starts, how far round it goes, its wobble, weight and how dry its tail runs. The same seed always draws the same ensō.
| Prop | Type | Default | What it does |
|---|---|---|---|
seed |
number |
7 |
Where it starts and lifts, its wobble, weight and dryness. |
weight |
number |
from the seed | Brush radius as a share of the canvas width. |
speed, color, bleed, delay |
As for BrushPaint. |
||
className, style |
Size the canvas here. |
import { ensoStroke, Fude, type FudeHandle } from "@tengubyte/sumi";
const brush = useRef<FudeHandle>(null);
<Fude ref={brush} label="Drawing area" onDrawing={setDrawing} style={{ width: "100%", aspectRatio: "4 / 3" }} />
// A hand draws an ensō on it.
await brush.current?.trace(ensoStroke());A sumi brush to draw with, by mouse, finger or pen. The brush goes in thin and presses, runs thinner and greyer when fast, and dries out bristle by bristle into streaks. A slow lift stops round; a quick one flicks off to a dry point. A pen's pressure changes the width. The strokes fade once the hand rests, unless fadeAfter is 0.
| Prop | Type | Default | What it does |
|---|---|---|---|
color |
`#${string}` |
"#161314" |
The ink. |
size |
number |
9 |
The brush's half width in CSS px at an even pace. |
dryAfter |
number |
1200 |
CSS px of stroke a loaded brush lasts before it runs dry. |
fadeAfter |
number |
1.6 |
Seconds of rest before the strokes fade; 0 keeps them. |
onDrawing |
(drawing: boolean) => void |
Told when the brush goes down and comes up, to change the page while drawing. | |
seed |
number |
1 |
Changes how much ink each bristle holds. |
label |
string |
What the drawing area is for. Without it the canvas is hidden from assistive technology. | |
className, style |
Size the canvas here. A resize wipes it. |
The ref's trace(stroke) draws a stroke as a hand would and resolves when the brush lifts; with reduced motion the stroke lands at once. A stroke is points as shares of the canvas (y down), with optional ms, pace(share) and pressure(share). ensoStroke(aspect?, radius?) gives a ready one. clear() wipes the paper.
import { Suminagashi } from "@tengubyte/sumi";
<Suminagashi pattern="flow" style={{ width: 480, aspectRatio: "4 / 3" }} />Drops of ink and clear water land on one spot in turn, each pushing the last outward into a ring, then a wave, a comb or a slow turn pulls the rings into flowing lines. Each step is an exact, area-preserving map of the water's surface, after Lu, Jaffer et al., "Mathematical Marbling" (2012), so every pixel runs the list backwards to find where its ink came from.
| Prop | Type | Default | What it does |
|---|---|---|---|
pattern |
"flow" | "rings" | "comb" |
"flow" |
A preset, laid out for the canvas's shape. |
ops |
MarbleOp[] |
Your own drops, ring systems, tines, waves and vortices instead. Positions in tray heights. | |
color |
`#${string}` |
"#161314" |
The ink. Pale rings are the same ink, thinner. |
accent |
`#${string}` |
"#d8001a" |
The one accent ring. |
delay |
number |
0 |
Seconds after it is in view. |
import { InkDrop } from "@tengubyte/sumi";
<InkDrop drops={[[0.38, 0.1], [0.64, 0.1, 0.9, "#d8001a"]]} style={{ width: "100%", aspectRatio: "4 / 3" }} />
<InkDrop view="top" kind="flick" style={{ width: "100%", aspectRatio: "4 / 3" }} />From the side, ink falls into still water. It is heavier than water, so each drop sinks; its thick patches fall faster than its thin ones, which rolls it into a cap that breaks into curls and threads. This view is a small fluid simulation (Stam's stable fluids) on half-float textures. It keeps velocity on a coarse grid and ink on a fine one, and steps 60 times a second. Devices that cannot render to half-float textures get a traced vortex ring instead.
From above, sumi ink lands on washi, drawn in code. Satellite drops skid as they land, so their tails point away from the landing, and spikes and streaks break into beads. After the black, a grey halo wicks into the paper fibres and slows as it goes. The top view needs no simulation, so any frame, the last one included, comes straight from the time.
| Kind | What lands |
|---|---|
drop |
A round blot, sometimes with a smaller drop merged into its rim, and a few satellites. |
splash |
A blot thrown out into spikes, beads past their tips, satellites and a little mist. |
flick |
A brush flicked along angle: a short smear, then curving streaks that end in teardrops. |
spray |
Grainy specks off a dry brush, thinning out from the middle, with no halo. |
bloom |
A soft, mottled grey stain that keeps spreading while it is wet. |
drip |
A drop that runs along angle, down by default, with a bulb leading. |
ring |
A drop that dries with its pigment pulled out to a dark rim, leaving the middle pale. |
| Prop | Type | Default | What it does |
|---|---|---|---|
view |
"side" | "top" |
"side" |
Watch the ink sink through water, or look down as it lands on paper. |
drops |
[x, y, start?, color?][] |
[[0.5, 0.1]]; top [[0.5, 0.5]], a drip [[0.5, 0.2]], a flick [[0.25, 0.7]] |
Up to three: where, as shares of the canvas (y down), when in seconds, and their ink. |
color |
`#${string}` |
"#161314" |
Ink for drops without their own. |
seed |
number |
1 |
How the ink breaks up, where the spikes point and where the satellites land. |
swirl |
number |
0.6 |
Side view: how much the water churns, 0 to 2. |
kind |
"drop" | "splash" | "flick" | "spray" | "bloom" | "drip" | "ring" |
"splash" |
Top view: what lands. |
size |
number |
1 |
Top view: how big each one lands, 0.2 to 3. |
angle |
number |
-30 for a flick, 90 for a drip, else 0 |
Top view: the way a flick throws, a drip runs or a spray stretches, in degrees clockwise from pointing right. |
speed, delay |
number |
1, 0 |
At speed 1 a drop takes 4.5 seconds to settle in water and 3 on paper. |
Side drops sink most of the way down, so start them near the top. From above, a splash's satellites reach about 45% of the canvas height from where it lands, a flick about half, and a drip runs down up to two thirds of it. Each kind is its own small shader, so a page that shows several kinds compiles one for each. Under reduced motion the side view runs the whole simulation once, as soon as the canvas is in view, and shows the last frame; the top view shows its settled frame. The top view renders at no more than half a megapixel.
import { InkWipe } from "@tengubyte/sumi";
<InkWipe src="/hero.webp" width={1200} height={674} alt="Mountains at dawn" blots={[[0.5, 0.5, 0]]} />The image comes in under ink that blooms from up to three blots. The edge spreads unevenly with feathered fibres, pigment pools dark at the front and the wash thins behind it until the picture shows, then the ink dries away.
| Prop | Type | Default | What it does |
|---|---|---|---|
src, width, height, alt |
required | The image and its own size in px. | |
from |
string |
An image in the same box that the ink wipes away. | |
blots |
[x, y, start][] |
[[0.5, 0.5, 0]] |
Where, as shares of the image (y down), and when, 0 to 1. |
color |
`#${string}` |
"#161314" |
The ink at the spreading edge. |
seed |
number |
1 |
Changes the shape of the bloom. |
duration, delay |
number |
1.8, 0 |
Seconds the bloom takes, and seconds to wait once in view. |
className, style |
Width alone keeps the image's shape. |
Like BrushReveal, it ends on the image's own pixels and hands back to a plain image.
import { inkTransition } from "@tengubyte/sumi";
import { flushSync } from "react-dom";
<button onClick={(e) => inkTransition(() => flushSync(() => setPage("water")), {
from: [e.clientX / innerWidth, e.clientY / innerHeight],
})}>Ink floods the window from a point until nothing shows, swap runs, then paper opens from the same point and pushes the ink off the edges. Call it with the change to make. It plays once per call and resolves when the ink has gone. If swap returns a promise, the ink stays down until it settles, so a route that loads data is covered until it is ready. Make the change synchronous, as flushSync does for React state, so the new page is in place before the ink opens.
| Option | Type | Default | What it does |
|---|---|---|---|
from |
[x, y] |
[0.5, 0.5] |
Where the ink floods from, as shares of the covered box. |
to |
[x, y] |
from |
Where the paper opens from. Outside 0 to 1 is past an edge. |
color |
`#${string}` |
"#161314" |
The ink. |
seed |
number |
1 |
Changes the shape of the fronts. |
duration |
number |
1.6 |
Seconds for the flood and the opening, not counting the wait for swap. |
within |
HTMLElement |
the window | Cover a positioned element instead, such as a carousel. |
Clicks pass through the ink to the page under it. With reduced motion, or without WebGL, it just calls swap.
import { TornBand, TornEdge } from "@tengubyte/sumi";
// The border tears away to a ragged frame.
<TornEdge src="/scroll.webp" width={1200} height={674} alt="A hanging scroll" />
// A paper cover tears off, leaving the plain image.
<TornEdge tear="cover" src="/scroll.webp" width={1200} height={674} alt="A hanging scroll" />
// A dark band whose bottom edge tears across, for the end of a section.
<TornBand color="#161314" style={{ width: "100%", height: 96 }} />Paper torn by hand. The edge wanders at three scales, a thin white core shows where the tear split the sheet, and long fibres reach past it. The torn sheet throws a soft shadow. The tear is measured in pixels, so a thumbnail and a hero tear alike.
| Prop | Type | Default | What it does |
|---|---|---|---|
src, width, height, alt |
required | TornEdge: the picture, and its own size in px. |
|
tear |
"frame" | "cover" |
"frame" |
TornEdge: tear the border off to a ragged frame, which stays, or tear a cover off and end on the plain image. |
side |
"bottom" | "top" |
"bottom" |
TornBand: which edge of the band is torn. |
color |
`#${string}` |
"#161314", cover "#dbd1bd" |
The band, or the cover's paper. |
edge |
`#${string}` |
"#fdfcfa" |
The white core along the tear. |
seed |
number |
1 |
Changes the path of the tear. |
duration, delay |
number |
1.4 (band 1.6), 0 |
Seconds the tear takes, and seconds to wait once in view. |
className, style |
TornEdge: width alone keeps the image's shape. TornBand: full width and 80 to 120 px tall suits a section edge. |
import { Irodori } from "@tengubyte/sumi";
<Irodori src="/view.webp" width={1200} height={800} alt="A red bridge in spring" blooms={[[0.3, 0.4, 0], [0.75, 0.6, 0.15]]} />The picture starts as an ink wash on paper, then colour seeps in from a few wet blooms. Each bloom has a ragged, lobed edge and a dark rim where the pigment pools, and grows until it meets its neighbours, so fewer blooms take as long as more. Until the colour starts, the browser greys the image, so it never shows in full colour first.
| Prop | Type | Default | What it does |
|---|---|---|---|
src, width, height, alt |
required | The picture and its own size in px. | |
blooms |
[x, y, start][] |
three | Up to five: where, as shares of the picture (y down), and when, 0 to 0.9 of the run. Outside 0 to 1 starts past an edge. |
paper, ink |
`#${string}` |
"#f5f1eb", "#161314" |
The light and the dark of the wash. |
seed |
number |
1 |
Changes the shape of the blooms. |
duration, delay |
number |
2.6, 0 |
Seconds the colour takes, and seconds to wait once in view. |
className, style |
Width alone keeps the image's shape. |
Like InkWipe, it ends on the image's own pixels and hands back to a plain image.
import { HakeCut, HakeStroke } from "@tengubyte/sumi";
// The picture keeps the top, above a brushed edge that falls to the right.
<HakeCut src="/header.webp" width={1200} height={800} alt="Castle walls" />
// The picture inside one brushed band.
<HakeCut shape="band" thickness={0.5} src="/header.webp" width={1200} height={800} alt="Castle walls" />
// One gold stroke, arching as it climbs.
<HakeStroke color="#c99e2b" thickness={0.22} bend={-0.5} angle={20} style={{ width: "100%", aspectRatio: "4 / 3" }} />One pass of a hake, the wide flat brush. Its bristles leave long streaks along the way it travels, the edges break up where the brush runs dry, and the head shows bristle tips as it moves. The bristles are measured in pixels, so they stay fine at any size. The brushed edge stays: the canvas keeps its last frame over the hidden image and redraws it on resize.
| Prop | Type | Default | What it does |
|---|---|---|---|
src, width, height, alt |
required | HakeCut: the picture and its own size in px. |
|
shape |
"edge" | "band" |
"edge" |
HakeCut: keep the picture on one side of a brushed edge, or inside one band. |
side |
"top" | "bottom" |
"top" |
HakeCut: which side of the edge keeps the picture. |
offset |
number |
0.2 (band 0) |
HakeCut: how far below the middle the edge or band runs, as a share of the height. |
thickness |
number |
0.6 (stroke 0.3) |
The band's share of the height, or the stroke's share of the canvas's shorter side. |
streaks |
number |
0 |
HakeCut: white streaks laid over the picture along its edge, 0 to 1. |
color |
`#${string}` |
"#161314" |
HakeStroke: the paint. |
bend |
number |
0 |
HakeStroke: positive sags in the middle, negative arches. |
angle |
number |
-12 (stroke 0) |
The way the brush travels, in degrees. Positive climbs. |
seed |
number |
1 |
Changes the bristles. |
duration, delay |
number |
1.3 (stroke 1), 0 |
Seconds the stroke takes, and seconds to wait once in view. |
className, style |
HakeCut: width alone keeps the image's shape. HakeStroke: size the canvas; the stroke runs through its middle. |
import { Tachigami, TachigamiText } from "@tengubyte/sumi";
// The top half sticks, slips down the cut and falls away.
<Tachigami src="/duel.webp" width={1200} height={800} alt="Two swordsmen at dawn" mode="fall" />
// A word cut in two with vermilion ink, the halves left apart.
<TachigamiText text="一閃" label="Issen, one flash" mode="apart" font='"Yuji Syuku"' color="#d8001a" style={{ width: "100%", aspectRatio: "4 / 3" }} />One slash of the brush. A dry ink stroke tears across, spraying drops, and the two halves shear apart along it with dark cut faces. By default they close again and the picture hands back to a plain image. mode="apart" leaves them parted, and mode="fall" lets the top half stick, slip down the cut and tip away; both stay on a canvas over the hidden image. TachigamiText draws its text from the font into a canvas and cuts that.
| Prop | Type | Default | What it does |
|---|---|---|---|
src, width, height, alt |
required | Tachigami: the picture and its own size in px. |
|
text, label |
string |
required, none | TachigamiText: the word, and what it says for screen readers. |
mode |
"rejoin" | "apart" | "fall" |
"rejoin" |
Close the cut again, leave the halves parted, or let the top half fall. |
through |
[x, y] |
[0.5, 0.5] |
A point the cut passes through, as shares of the box (y down). |
angle |
number |
17 |
The slash's direction in degrees. Positive climbs. |
color |
`#${string}` |
"#161314" |
The ink of the stroke, the spray and the cut faces. |
glint |
boolean |
false |
A white core along the blade, for dark pages. |
font, weight, textColor |
Mincho, 900, "#161314" |
TachigamiText: how the word is drawn. The font loads before the cut. |
|
seed |
number |
1 |
Changes the stroke's streaks and the spray. |
duration, delay |
number |
1.6 (fall 2), 0 |
Seconds the cut takes, and seconds to wait once in view. |
className, style |
Tachigami: width alone keeps the image's shape. TachigamiText: size the canvas. |
import { Kintsugi } from "@tengubyte/sumi";
<Kintsugi src="/scene.webp" width={1200} height={800} alt="A red sun over blue hills" impact={[0.42, 0.48]} />Kintsugi (金継ぎ) mends broken pottery with lacquer and gold. The picture shakes and cracks run out from the blow, small shards near it and a few long cracks reaching the edges, each tapering to a point. Red urushi fills the cracks from the blow outward, then gold is dusted on, and a sheen crosses the seams once. The mended picture stays on a canvas over the hidden image. Only its opaque part breaks, so a cut-out figure cracks and the space round it stays clear. The seams are measured in pixels, so a thumbnail and a hero get the same line.
| Prop | Type | Default | What it does |
|---|---|---|---|
src, width, height, alt |
required | The picture and its own size in px. | |
impact |
[x, y] |
[0.45, 0.45] |
Where the blow lands, as shares of the picture (y down). |
seed |
number |
1 |
Changes the cracks. |
duration, delay |
number |
3.4, 0 |
Seconds from the blow until the sheen has crossed, and seconds to wait once in view. |
className, style |
Sizes the wrapper. Width alone keeps the image's shape. |
import { Kasumi, kasumiTransition } from "@tengubyte/sumi";
import { flushSync } from "react-dom";
// Two bands slide in from either side and settle, between sections.
<Kasumi style={{ width: "100%", height: 120 }} />
// Five bands close over the page, it changes, and they drift out the far side.
<button onClick={() => kasumiTransition(() => flushSync(() => setPage("dye")))}>Suyari-gasumi, the long gold cloud bands that picture scrolls use to part one scene from the next. Each band is flat gold leaf laid in squares, its ends stepping in as rounded lobes, with gold dust and cut flakes gathered near the edge and a slow sheen across it. kasumiTransition works like inkTransition. It plays once per call, waits for swap with the page hidden, and resolves when the bands have gone. Make the change synchronous, as flushSync does for React state.
| Prop or option | Type | Default | What it does |
|---|---|---|---|
seed |
number |
1 |
Changes the bands' stepped ends and their leaf. |
duration |
number |
1.4, transition 1.8 |
Kasumi: seconds until both bands settle. kasumiTransition: seconds to close and open, not counting the wait for swap. |
delay |
number |
0 |
Kasumi: seconds to wait once in view. |
within |
HTMLElement |
the window | kasumiTransition: cover a positioned element instead. |
className, style |
Kasumi: size the canvas. Full width and 80 to 160 px tall suits a divider. |
Clicks pass through the bands to the page under them. With reduced motion, or without WebGL, kasumiTransition just calls swap.
import { Sansui } from "@tengubyte/sumi";
<Sansui seed={8} style={{ width: "100%", aspectRatio: "16 / 9" }} />An ink-wash landscape drawn in code. Five ridges are brushed in from far to near; each ridge line goes on left to right, then its wash creeps down and thins into mist. The sun bleeds in like vermilion on damp paper and bands of mist drift across it. It runs about 6.5 seconds at speed 1.
| Prop | Type | Default | What it does |
|---|---|---|---|
seed |
number |
3 |
Changes every ridge. |
sun |
boolean |
true |
A sun behind the far ridges, half veiled by mist. |
mist |
number |
0.6 |
How much mist lies between the ridges, 0 to 1. |
color, sunColor |
`#${string}` |
"#161314", "#d8001a" |
The ink and the sun. |
speed, delay |
number |
1, 0 |
2 paints twice as fast; seconds after it is in view. |
className, style |
Size the canvas here; wide shapes suit it. |
import { Shibori } from "@tengubyte/sumi";
<Shibori pattern="arashi" dips={4} style={{ width: "100%", aspectRatio: "3 / 2" }} />Shibori (絞り) is Japanese tie-dye. The cloth goes into the vat bottom first, comes out yellow-green and turns blue in the air, and each dip goes darker. The dye darkens the way light through it does, so a pale dip of indigo is sky blue, not grey. Where the cloth was bound the dye cannot reach, and it creeps in a little at the edge. The weave is measured in pixels. Three dips run about 4.5 seconds at speed 1.
| Prop | Type | Default | What it does |
|---|---|---|---|
pattern |
"arashi" | "itajime" | "kanoko" |
"itajime" |
Pleated on a pole, folded and clamped between round boards, or tied in tiny points. |
dips |
number |
3 |
Dips in the vat, 1 to 6. Each one darker. |
color |
`#${string}` |
"#122645" |
The dye at its deepest, after six dips. |
cloth |
`#${string}` |
"#ede8d9" |
The undyed cloth, which the resist keeps. |
seed |
number |
1 |
Changes the folds, the mottling and the weave. |
speed, delay |
number |
1, 0 |
2 dyes twice as fast; seconds after it is in view. |
className, style |
Size the canvas here. |
import { Komorebi } from "@tengubyte/sumi";
<div style={{ position: "relative" }}>
<Komorebi style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
<h2>Tea in the garden</h2>
</div>Komorebi (木漏れ日) is sunlight through leaves. Each gap in the canopy works as a pinhole, so the light lands as round pictures of the sun, all one size for one layer of leaves, and a low sun stretches them long. Near leaves cast ragged patches instead. The wind sways the branches and opens and shuts the gaps. With follow the pointer moves the sun: across the canvas turns it, down the canvas lowers it. Like Hinoko it runs while the canvas is on screen; with reduced motion it holds one still frame. It is decoration, hidden from assistive technology.
| Prop | Type | Default | What it does |
|---|---|---|---|
sun |
[azimuth, elevation] |
[35, 55] |
In degrees. The azimuth is the way the light falls, 0 to the right and 90 down. An elevation toward 20 stretches the light. |
follow |
boolean |
true |
The pointer moves the sun. |
paper |
`#${string}` |
"#efe8dc" |
The paper under the leaves. |
seed |
number |
1 |
Changes the leaves. |
speed, delay |
number |
1, 0 |
How fast the wind moves the leaves, 0 for still air; seconds to wait once in view. |
className, style |
Size the canvas here, and lay content over it. |
import { InkSplat } from "@tengubyte/sumi";
<InkSplat seed={12} angle={160} style={{ width: 360, aspectRatio: "360 / 396" }} />The splat is drawn in code from the seed. Size the canvas with CSS; the splat scales with its width. With the defaults the pool lands just past the right edge, so only its fingers and drops reach in.
| Prop | Type | Default | What it does |
|---|---|---|---|
seed |
number |
3.7 |
Changes every finger, bead and droplet. |
angle |
number |
160 |
Direction of travel, degrees clockwise from pointing right. |
x, y |
number |
1.04, 0.3 |
Where the pool lands, as a share of the canvas. |
size |
number |
0.12 |
Pool radius as a share of the canvas width. |
color |
`#${string}` |
"#d8001a" |
The main ink. |
underColor |
`#${string}` | null |
"#161314" |
A smaller second splat under the first, or null. On dark backgrounds use a lighter charcoal. |
mist |
number |
0.55 |
How much mist, 0 to 1. |
duration, delay |
number |
2.2, 0 |
Seconds until the last mist lands, and seconds to wait once in view. |
import { Hinoko } from "@tengubyte/sumi";
<div style={{ position: "relative", background: "#100d0e" }}>
<img src="/castle.webp" width={1200} height={674} alt="A castle on fire" />
<Hinoko wind={0.3} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
</div>Embers off a fire. Each one leaves hot and nearly white, cools to red as it climbs, and sways in the eddies over the heat. A few are flakes of ash that tumble. Embers are light, so they add up on a dark page and vanish on a light one.
Unlike the other effects it keeps going: the fire runs while the canvas is on screen and stops costing anything when it scrolls away. With burst it throws one shower once in view and lets it die down. With reduced motion nothing rises.
| Prop | Type | Default | What it does |
|---|---|---|---|
count |
number |
120 |
Embers in the air at once, at most 400. |
burst |
boolean |
false |
One shower that dies down, instead of a fire that keeps going. |
from |
[x, y] |
[0.5, 1] |
Where they leave from, as shares of the canvas (y down). |
spread |
number |
1 (burst 0.1) |
The share of the width they leave across. |
wind |
number |
0.15 |
Sideways drift, -1 to 1. |
ash |
number |
0.1 |
The share that are tumbling flakes of ash. |
seed |
number |
1 |
Changes every ember's path. |
speed, delay |
number |
1, 0 |
2 runs twice as fast; seconds to wait once in view. |
import { Kaen } from "@tengubyte/sumi";
<Kaen style={{ width: "100%", aspectRatio: "2 / 1" }} />
<Kaen text="炎" label="Flame" look="night" style={{ width: 320, aspectRatio: "1" }} />
<Kaen src="/tengu.webp" label="A crow tengu seated on a rock" style={{ width: "100%", aspectRatio: "4 / 3" }} />Fire (火炎) as the Heiji scroll and the Blue Fudo paint it. The flames are one mass: tongues rise to points, sway more the higher they reach and break off at the top while new ones come up from below. Fine pale and dark strands run up each tongue, a pale line runs just inside the outline, and slow whorls turn the flow.
It burns along the bottom edge of the canvas, round a character with text, or with src round the outline of a picture with a transparent background, and climbs above it. An opaque picture burns round its rectangle, and one from another origin must allow CORS, since the fire reads its pixels. The fire paints its own colours, so pick the look for the page: "shu" and "sumi" for paper, "night" for a dark page. Like Hinoko it runs while the canvas is on screen; with reduced motion it holds one frame of the grown fire. It needs WebGL's standard derivatives, which nearly every browser has.
| Prop | Type | Default | What it does |
|---|---|---|---|
text |
string |
none | One character, or two, to set alight. Without it, or src, the fire burns along the bottom edge. |
src |
string |
none | A picture with a transparent background to set alight round its outline, in place of text. |
label |
string |
none | What the character or picture shows. Without it the canvas is hidden from assistive technology. |
look |
"shu" | "sumi" | "night" |
"shu" |
Vermilion on paper, black ink, or brighter for a dark page. |
reach |
number |
0.6 (text, src 0.13) |
How high the flames climb, as a share of the canvas's shorter side. |
font, weight |
string, number |
a Mincho stack, 700 |
A CSS font family you load. The fire waits for it. |
color |
`#${string}` |
by look | The character: sumi on "shu", vermilion on "sumi", paper white on "night". |
seed |
number |
1 |
Changes every tongue and whorl. |
speed, delay |
number |
1, 0 |
2 burns twice as fast; seconds to wait once in view. |
import { Shuchusen } from "@tengubyte/sumi";
<div style={{ position: "relative", aspectRatio: "4 / 3" }}>
<Shuchusen focus={[0.6, 0.4]} style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} />
<h2>斬</h2>
</div>Manga focus lines (集中線). Spikes shoot in from past the edges, taper to points round a clear oval and are drawn anew ten times a second, so they shiver the way hand-inked frames do. lines="speed" streams spindles past side by side instead. Like Hinoko it runs while the canvas is on screen; with reduced motion it holds one still drawing.
| Prop | Type | Default | What it does |
|---|---|---|---|
lines |
"focus" | "speed" |
"focus" |
Lines that run in toward a point, or stream past side by side. |
focus |
[x, y] |
[0.5, 0.5] |
The point they run toward, as shares of the canvas (y down). |
clear |
[x, y] |
[0.32, 0.3] |
Half the width and half the height of the clear oval, as shares of the canvas. |
count |
number |
150 |
How many lines go round, 8 to 600. |
weight |
number |
1 |
Line thickness; 2 is twice as heavy. |
color |
`#${string}` |
"#161314" |
The ink. |
fps |
number |
10 |
How often the lines are drawn anew; 0 keeps one drawing. |
angle, speed |
number |
0, 1 |
Speed lines: their direction in degrees, and how fast they stream. |
seed, delay |
number |
1, 0 |
Changes every line; seconds to wait once in view. |
import { Hanasaka } from "@tengubyte/sumi";
<Hanasaka from={[1.04, 0.06]} angle={-146} style={{ width: "100%", aspectRatio: "4 / 3" }} />A cherry branch brushed in stroke by stroke, thick at the start and thinning as it forks. Buds swell along it and blossoms burst open one after another, each with an ink outline and a dark heart. Then a few petals let go and tumble away on the wind. The last frame, the branch in full bloom, stays. It is decoration, hidden from assistive technology.
| Prop | Type | Default | What it does |
|---|---|---|---|
from |
[x, y] |
[-0.04, 0.8] |
Where the branch starts, as shares of the canvas (y down). Past an edge, it grows in from outside. |
angle |
number |
26 |
The way it grows, in degrees. Positive climbs. |
reach |
number |
1 |
How far it grows. |
flowers, petals |
number |
22, 10 |
Blossoms along the branch, up to 28, and petals that fall, up to 16. |
wind |
[x, y] |
[0.06, 0.02] |
Where the falling petals drift, per second, as shares of the shorter side (y down). |
bark, outline |
`#${string}` |
"#291f1c", "#3b1f26" |
The branch's ink, and the line round each petal. |
pink, heart |
`#${string}` |
"#f7bfd1", "#db6185" |
The petals, and the deep pink at each flower's heart. |
seed |
number |
1 |
Grows a different branch. |
duration, delay |
number |
5, 0 |
Seconds from the first stroke to the last petal, and seconds to wait once in view. |
import { InkFlick, inkFlick } from "@tengubyte/sumi";
<InkFlick>
<button>Order tea</button>
</InkFlick>
// Or from any click handler:
<button onClick={(e) => inkFlick(e)}>Order tea</button>A brush flicked at the paper. A short smear lands where the click was, streaks fan out along the throw and end in teardrop beads, and the smallest drops land furthest out. InkFlick throws the way the pointer was moving as it clicked, or a random way from rest, and the click still reaches its children. inkFlick takes anything with clientX and clientY, such as a click event, and throws from that point.
The ink lands on one canvas over the window that clicks pass through, and it stays on its spot of the page as the page scrolls. It holds a moment, then fades, and the canvas leaves the page once no ink is left. At most 12 flicks show at once. With reduced motion, or without WebGL, nothing is thrown.
| Prop or option | Type | Default | What it does |
|---|---|---|---|
size |
number |
1 |
How far it throws, 0.2 to 3. At 1 it reaches about 120 px end to end. |
color |
`#${string}` |
"#161314" |
The ink. |
fadeAfter |
number |
1.4 |
Seconds the ink stays before it fades, which takes 0.8 more. |
seed |
number |
a new one each throw | Changes the streaks and the drops. |
angle |
number |
random | inkFlick only: the way it is thrown, in degrees, 0 to the right and 90 down. |
children, className, style |
InkFlick: what a click throws ink from, wrapped in a div. |
import { Nijimi } from "@tengubyte/sumi";
<Nijimi style={{ aspectRatio: "16 / 9" }}>
<h2>Move the pointer here</h2>
</Nijimi>
// Or over the whole window:
<Nijimi page />Nijimi (滲み) is ink bleeding into paper. The pointer drags a wet brush: a slow stroke lays a wide, wet line, a fast one runs dry and breaks into hair streaks, and a pause pools a dot. Water wicks out past the black into the fibres and leaves a darker tide line where it stops. The ink dries a little lighter and fades, the oldest first.
The ink follows the pointer over the box, or over the whole window with page, on a canvas over the content that clicks pass through. It draws only while ink shows. With reduced motion, or without WebGL, nothing is drawn.
| Prop | Type | Default | What it does |
|---|---|---|---|
page |
boolean |
false |
Follow the pointer over the whole window instead of the box. |
size |
number |
1 |
2 lays a line twice as wide, 0.3 to 3. |
life |
number |
2.6 |
Seconds the ink lasts before it has faded. |
color |
`#${string}` |
"#161314" |
The ink. On a dark page use a light one. |
seed |
number |
1 |
Changes the rough edge and the hair streaks. |
children, className, style |
Nijimi is a positioned box round its children. |
import { SenkoHanabi } from "@tengubyte/sumi";
<SenkoHanabi progress={loaded} label="Loading" onDone={() => setReady(true)} style={{ width: 240, aspectRatio: "1" }} />Senko hanabi (線香花火) is the small hand-held sparkler of Japanese summers, and it burns in named stages. progress, 0 to 1, runs them: the molten bead swells, a few strong sparks pop, sparks fork again and again like pine needles, long thin ones droop like willow, and single sparks drop off as the bead dims. A new progress moves the sparkler on without relighting it. At 1 the bead drops, and onDone is called 0.6 s later, even off screen or without WebGL. Without progress it burns down by itself over duration.
Sparks are light, so set it on a dark page. It runs while the canvas is on screen; with reduced motion the sparks hold still. Without WebGL nothing shows, so keep a text fallback for the load.
| Prop | Type | Default | What it does |
|---|---|---|---|
progress |
number |
none | How far the load has come, 0 to 1. Without it the sparkler burns down by itself. |
onDone |
() => void |
none | Called once, when the bead has dropped. |
label |
string |
none | What is loading. It makes the canvas a progressbar; without it the canvas is hidden from assistive technology. |
duration |
number |
8 |
Seconds a sparkler left to itself takes to burn down. |
seed, delay |
number |
1, 0 |
Changes the sparks; seconds to wait once in view. |
className, style |
Size the canvas here, about square. |
import { bloomBranch, castKomorebi, colorIn, dropInk, dyeShibori, ensoStroke, floatInk, focusLines, fudePad, hakeIn, inkFlick, inkTrail, inkTransition, kasumiTransition, kindleFire, lightSenko, mendIn, paintEnso, paintHake, paintIn, paintKasumi, paintMark, paintSansui, paintStrokes, pressSeal, riseEmbers, slashIn, slashText, strikeKanji, tearBand, tearIn, throwSplat, wipeIn } from "@tengubyte/sumi/core";
// The image must fill a positioned parent; the brush draws on a canvas laid over it.
const stopPaint = paintIn(document.querySelector("img.enso")!, { sweep: "ring" });
const stopWrite = paintStrokes(document.querySelector("canvas.kanji")!, svgMarkup, { weight: 0.03 });
const stopEnso = paintEnso(document.querySelector("canvas.enso")!, { seed: 42 });
const stopFloat = floatInk(document.querySelector("canvas.water")!, { pattern: "rings" });
const stopSplat = throwSplat(document.querySelector("canvas.splat")!, { seed: 12 });
// A positioned, one-line element; the image must fill a positioned parent.
const stopMark = paintMark(document.querySelector<HTMLElement>("span.key")!, "circle");
const stopWipe = wipeIn(document.querySelector("img.hero")!, { blots: [[0.5, 0.5, 0]] });
const stopHills = paintSansui(document.querySelector("canvas.hills")!, { seed: 8 });
const stopSeal = pressSeal(document.querySelector("canvas.seal")!, "天狗", { shape: "tall", white: true });
const stopDrop = dropInk(document.querySelector("canvas.water")!, { drops: [[0.5, 0.1]] });
const stopStrike = strikeKanji(document.querySelector("canvas.death")!, "死", { glow: true });
// The image must fill a positioned parent; the tear runs on a canvas laid over it.
const stopTear = tearIn(document.querySelector("img.scroll")!, { tear: "frame" });
const stopBand = tearBand(document.querySelector("canvas.edge")!, { side: "top" });
// The image must fill a positioned parent; the colour and the brush run on a canvas laid over it.
const stopColor = colorIn(document.querySelector("img.view")!, { seed: 2 });
const stopCut = hakeIn(document.querySelector("img.header")!, { angle: -16 });
const stopSwash = paintHake(document.querySelector("canvas.swash")!, { color: "#c99e2b", bend: -0.5 });
const stopLines = focusLines(document.querySelector("canvas.lines")!, { focus: [0.6, 0.4] });
const stopEmbers = riseEmbers(document.querySelector("canvas.embers")!, { wind: 0.3 });
const stopFire = kindleFire(document.querySelector("canvas.fire")!, { text: "炎", look: "night" });
// The image must fill a positioned parent; the cut runs on a canvas laid over it.
const stopSlash = slashIn(document.querySelector("img.duel")!, { mode: "fall" });
const stopWord = slashText(document.querySelector("canvas.word")!, "一閃", { color: "#d8001a" });
const stopBloom = bloomBranch(document.querySelector("canvas.branch")!, { seed: 5 });
// The image must fill a positioned parent; the mended picture stays on a canvas laid over it.
const stopMend = mendIn(document.querySelector("img.bowl")!, { impact: [0.4, 0.5] });
const stopMist = paintKasumi(document.querySelector("canvas.divider")!, { seed: 3 });
const stopDye = dyeShibori(document.querySelector("canvas.cloth")!, { pattern: "kanoko" });
const stopLight = castKomorebi(document.querySelector("canvas.leaves")!, { sun: [35, 40] });
// A positioned element; leave `within` out to follow the pointer over the whole window.
const stopTrail = inkTrail({ within: document.querySelector<HTMLElement>("section.hero")! });
const sparkler = lightSenko(document.querySelector("canvas.loader")!, { progress: 0, onDone: () => showPage("tea") });
sparkler.progress(0.4);
document.addEventListener("click", (event) => inkFlick(event));
const pad = fudePad(document.querySelector("canvas.sheet")!, { fadeAfter: 0 });
await pad?.trace(ensoStroke());
await inkTransition(() => showPage("water"), { from: [0.9, 0.1] });
await kasumiTransition(() => showPage("dye"));Each call returns its cleanup, except these. inkTransition and kasumiTransition return a promise. fudePad returns a pad with trace, clear and stop, or null without WebGL. lightSenko returns a handle with progress and stop. inkFlick returns nothing, since its ink fades by itself.
- Plays once, when it has scrolled a little way into view.
inkTransitionandkasumiTransitionplay once per call,Hinoko,Kaen,Shuchusen,KomorebiandSenkoHanabirun while they are on screen,Fudedraws when the visitor does, andInkFlickandNijimidraw when the pointer clicks or moves. - With
prefers-reduced-motion: reduceit shows the last frame: the finished art, the settled water, the pressed seal, the torn frame, the brushed edge, the parted halves, the mended picture, the settled bands, the dyed cloth, the branch in bloom or the landed splat.inkTransitionandkasumiTransitionjust swap, a torn cover is already off,Irodoriand a closedTachigamicut show the plain image, focus lines, fire, the leaves and the sparkler hold still, no embers rise, no ink is flicked or trailed and a tracedFudestroke lands at once. - Without WebGL,
BrushReveal,InkWipe,Irodori,HakeCut,Tachigami,KintsugiandTornEdgeshow the plain image,BrushMarkshows the plain words,inkTransitionandkasumiTransitionjust swap, and the canvas and pointer effects show nothing. BrushReveal,InkWipe,Irodori, a closedTachigamicut and a torn cover end on the image's own pixels, hand back to the plain image and release their WebGL context, andinkTransitionandkasumiTransitionremove their canvas once the page shows again. The canvas effects, a torn frame,HakeCut,Kintsugiand parted or fallen halves keep their last frame and redraw it on resize.FudeandNijimikeep their context while they are on the page, and everyInkFlickshares one canvas that leaves once its ink has faded.- Each effect holds one WebGL context while it keeps its last frame, and browsers keep about 16 alive per page. Past that the oldest is dropped and its canvas leaves the page, so spread long runs of effects across pages.
- Every component is marked
"use client"for React Server Components.
npm install
npm run dev # the showcase site, importing the package from src
npm run check # types, lint, tests, package build and site buildThe site in site/ deploys to Cloudflare Pages from main: build command npm run build:site, output directory site/dist.
The code is MIT licensed. The ensō, band, landscape and tengu art used on the showcase site belong to TenguByte and are not part of the package. The KanjiVG files in site/src/kanji are CC BY-SA 3.0, Ulrich Apel, and are not part of the package either.