Skip to content

Native button with living ink

Try the native button

Not activated yet

Read as Markdown

Keep the button’s label, click, keyboard activation, focus ring, and disabled state native. Pibbl supplies only the decorative background. This example uses plain modules and createElement, so it does not need Pibbl JSX or a framework-specific adapter.

Activate the live button with a pointer or Enter/Space. Disable it, then switch its effect off and on. The activation count is ordinary HTML application state. Disabling the effect disposes Pibbl while preserving the button.

Without JavaScript, the control retains its CSS background and native semantics. A real application’s action belongs in its own click/submit handler. This demo uses type="button"; use type="submit" inside your existing form when submission is intended. The effect never cancels events or forwards synthetic clicks.

Download the Claude Design integration kit and open native-button/. Its README explains installing a packed copy of this checkout and running Vite. No external images, fonts, private repository helpers, or shaders are required. The texture runtime includes its own required assets.

The existing editor remains available for Living Ink, which teaches the same public texture API. The HTML-owned recipe runs here and as the standalone folder; it is not yet imported into the Canvas-only editor workflow.

mountInkButton(button, canvas) mounts once and returns { update, dispose }. Native events update a signal. A useReaction reads it and sends texture messages after rendering. Disabled attribute changes are observed; update() is also available after host state changes. Call dispose() before removing the host during route or framework teardown.

Pibbl’s responsive viewport observes the canvas content box. useLayoutBox fills that allocation. CSS matches the canvas to the native button; density is capped at two while simulation resolution stays 128 by 48. The host clips with a border radius, and the canvas never intercepts input.

Reduced motion, disabled state, document visibility, and intersection pause the texture. Reduced motion shows a frozen seeded field and suppresses interaction paint doses. Disabling the effect removes it entirely. If mounting fails, the HTML control and dark CSS background remain usable.

This is one small simulation. Do not multiply it across a product grid without measuring representative devices. Pibbl owns its frame loop; the host creates none. Cleanup aborts event listeners, disconnects observers, and disposes the root and its hook-owned texture.

Detailed companion · reactionDiffusion · useTexture · pibbl · Visual catalog

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Native button with Pibbl ink</title>
<link rel="stylesheet" href="./button.css" />
</head>
<body>
<h1>Native button with Pibbl ink</h1>
<span class="ink-button-host">
<canvas id="ink-canvas" aria-hidden="true" hidden></canvas>
<button id="ink-button" type="button">Try Pibbl</button>
</span>
<p>
<label><input id="disabled" type="checkbox" /> Disable button</label>
</p>
<p>
<label><input id="effect" type="checkbox" checked /> Enable effect</label>
</p>
<p id="result" role="status">Not activated yet</p>
<script type="module" src="./main.js"></script>
</body>
</html>
.ink-button-host {
position: relative;
display: inline-grid;
isolation: isolate;
border-radius: 6px;
background: #124c50;
max-width: 100%;
}
.ink-button-host canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: inherit;
pointer-events: none;
}
.ink-button-host button {
position: relative;
margin: 0;
border: 1px solid #124c50;
border-radius: inherit;
padding: 16px 32px;
min-height: 48px;
color: white;
background: #001b2699;
font:
600 18px/1.4 system-ui,
sans-serif;
cursor: pointer;
}
.ink-button-host button:focus-visible {
outline: 3px solid #bc3e17;
outline-offset: 4px;
}
.ink-button-host button:disabled {
background: #465457;
color: #fff;
cursor: not-allowed;
}
.ink-button-host button:active:not(:disabled) {
background: #001b26bb;
}
import {
pibbl,
createElement,
Rectangle,
signal,
useLayoutBox,
useReaction,
} from "@pibbl/core";
import { useTexture, reactionDiffusion } from "@pibbl/core/textures";
// Application integration code, not a Pibbl-exported widget.
export function mountInkButton(button, canvas) {
const events = new AbortController();
const motion = matchMedia("(prefers-reduced-motion: reduce)");
let visible = true;
let disposed = false;
let controller;
let texture;
let previousPointer;
const state = signal({ active: false, pressed: false, paused: true });
const sync = () => {
if (disposed) return;
const disabled = button.matches(":disabled");
state.set({
active:
!disabled &&
(button.matches(":hover") || document.activeElement === button),
pressed: !disabled && button.matches(":active"),
paused: disabled || motion.matches || document.hidden || !visible,
});
};
function Background() {
const { width, height } = useLayoutBox();
const ink = useTexture(reactionDiffusion({ seed: 42, preset: "stripes" }), {
resolution: { width: 128, height: 48 },
palette: ["#031a22", "#087c80", "#38c8a5", "#a8f5d8"],
});
useReaction(() => {
texture = ink;
return () => {
texture = undefined;
};
}, [ink]);
useReaction(() => {
const current = state.get();
ink.send({ type: "pause", paused: current.paused });
if (current.active && !current.paused) {
ink.send({
type: "paint",
u: 0.5,
v: 0.5,
radius: current.pressed ? 12 : 6,
strength: 0.8,
});
}
canvas.dataset.inkState = current.paused ? "paused" : "running";
});
return createElement(Rectangle, {
pointerEvents: "none",
style: { width, height, fill: ink },
});
}
const attributes = new MutationObserver(sync);
const canDisturb = () =>
texture &&
!disposed &&
!button.matches(":disabled") &&
!motion.matches &&
!document.hidden &&
visible;
const point = (event) => {
const box = button.getBoundingClientRect();
return {
u: Math.max(0, Math.min(1, (event.clientX - box.left) / box.width)),
v: Math.max(0, Math.min(1, (event.clientY - box.top) / box.height)),
};
};
const stir = (event) => {
const next = point(event);
const last = previousPointer;
previousPointer = next;
if (!last || !canDisturb()) return;
// Convert CSS motion to normalized texture displacement, bounded per event.
texture.send({
type: "stir",
...next,
du: Math.max(-0.3, Math.min(0.3, (next.u - last.u) * 2.5)),
dv: Math.max(-0.3, Math.min(0.3, (next.v - last.v) * 2.5)),
radius: 16,
});
texture.send({ type: "paint", ...next, radius: 4, strength: 0.3 });
};
const burst = (event) => {
if (!canDisturb()) return;
// Keyboard activation has no pointer location; use the center instead.
const center = event.detail === 0 ? { u: 0.5, v: 0.5 } : point(event);
for (let i = 0; i < 20; i++) {
const angle = (i * Math.PI * 2) / 20;
const x = Math.cos(angle),
y = Math.sin(angle);
const position = {
u: center.u + (x * 12) / 128,
v: center.v + (y * 12) / 48,
};
texture.send({
type: "stir",
...position,
du: x * 0.15,
dv: y * 0.3,
radius: 10,
});
texture.send({ type: "paint", ...position, radius: 3, strength: 0.95 });
}
};
const intersection = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? false;
sync();
});
function dispose() {
if (disposed) return;
disposed = true;
events.abort();
attributes.disconnect();
intersection.disconnect();
controller?.dispose();
canvas.hidden = true;
canvas.dataset.inkState = "disposed";
}
try {
button.addEventListener(
"pointerenter",
(event) => {
previousPointer = point(event);
},
{ signal: events.signal },
);
button.addEventListener(
"pointerleave",
() => {
previousPointer = undefined;
},
{ signal: events.signal },
);
button.addEventListener(
"pointercancel",
() => {
previousPointer = undefined;
},
{ signal: events.signal },
);
button.addEventListener("pointermove", stir, { signal: events.signal });
button.addEventListener("click", burst, { signal: events.signal });
for (const event of [
"pointerenter",
"pointerleave",
"pointerdown",
"pointerup",
"pointercancel",
"focus",
"blur",
"keydown",
"keyup",
]) {
button.addEventListener(event, sync, { signal: events.signal });
}
window.addEventListener("pointerup", sync, { signal: events.signal });
document.addEventListener("visibilitychange", sync, {
signal: events.signal,
});
motion.addEventListener("change", sync, { signal: events.signal });
attributes.observe(button, {
attributes: true,
attributeFilter: ["disabled"],
});
// Disabled fieldsets affect native buttons too.
for (
let parent = button.parentElement;
parent;
parent = parent.parentElement
) {
if (parent instanceof HTMLFieldSetElement)
attributes.observe(parent, {
attributes: true,
attributeFilter: ["disabled"],
});
}
intersection.observe(button);
sync();
canvas.hidden = false;
controller = pibbl(canvas, createElement(Background, {}), {
viewport: "responsive",
pixelRatio: Math.min(devicePixelRatio || 1, 2),
});
} catch (error) {
dispose();
throw error;
}
return { update: sync, dispose };
}
import { mountInkButton } from "./ink-button.js";
const button = document.querySelector("#ink-button");
const canvas = document.querySelector("#ink-canvas");
const events = new AbortController();
let effect;
let count = 0;
function start() {
try {
effect = mountInkButton(button, canvas);
} catch (error) {
console.warn(
"Pibbl decoration unavailable; native button remains usable.",
error,
);
}
}
button.addEventListener(
"click",
() => {
count++;
document.querySelector("#result").textContent =
`Activated ${count} ${count === 1 ? "time" : "times"}`;
},
{ signal: events.signal },
);
document.querySelector("#disabled").addEventListener(
"change",
(event) => {
button.disabled = event.target.checked;
effect?.update();
},
{ signal: events.signal },
);
document.querySelector("#effect").addEventListener(
"change",
(event) => {
effect?.dispose();
effect = undefined;
if (event.target.checked) start();
},
{ signal: events.signal },
);
start();
// A framework/router should call this before removing the host.
export function dispose() {
effect?.dispose();
events.abort();
}
window.addEventListener("pagehide", () => effect?.dispose(), {
signal: events.signal,
});
window.addEventListener(
"pageshow",
(event) => {
if (event.persisted && document.querySelector("#effect").checked) start();
},
{ signal: events.signal },
);
if (import.meta.hot) import.meta.hot.dispose(dispose);

Documentation built with @pibbl/core 0.0.2, revision c321a83. ALPHA — NOT FOR PRODUCTION USE.