Scribble
import { useEffect, useId, useRef, useState } from 'react';
// A marker going back and forth, never quite reaching the same line twice.
const HATCH = (() => {
let d = 'M26 118';
let up = true;
for (let x = 26; x < 190; x += 8) {
const wobble = Math.sin(x * 1.7) * 4;
d += ` L${(up ? x + 13 : x - 3).toFixed(1)} ${(up ? 60 + wobble : 124 + wobble).toFixed(1)}`;
up = !up;
}
return d;
})();
const css = `
.btn-scribble { flex: none; display: block; width: 240px; height: 176px; padding: 0; border: 0; border-radius: 12px; background: none; color: light-dark(#373a40, #f1f2f3); cursor: pointer; }
.btn-scribble:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.btn-scribble svg { display: block; width: 100%; height: 100%; overflow: visible; }
.btn-scribble__line { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; }
.btn-scribble__hatch { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 5.5; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.3s ease-in; }
.btn-scribble:hover .btn-scribble__hatch, .btn-scribble:focus-visible .btn-scribble__hatch { stroke-dashoffset: 0; transition: stroke-dashoffset 0.8s cubic-bezier(0.3, 0.1, 0.6, 1); }
.btn-scribble text { fill: currentColor; font-family: 'Bradley Hand', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive; font-weight: 700; }
.btn-scribble__word { font-size: 36px; text-anchor: middle; }
.btn-scribble__draw { stroke-dasharray: 1; stroke-dashoffset: 1; }
.btn-scribble[data-done='true'] .btn-scribble__strike { stroke-dashoffset: 0; transition: stroke-dashoffset 0.34s linear; }
.btn-scribble[data-done='true'] .btn-scribble__arrow { stroke-dashoffset: 0; transition: stroke-dashoffset 0.22s linear 0.86s; }
.btn-scribble__reply { font-size: 25px; clip-path: inset(-12px 100% -12px -4px); }
.btn-scribble[data-done='true'] .btn-scribble__reply { clip-path: inset(-12px -8px -12px -4px); transition: clip-path 0.56s steps(10) 0.36s; }
.btn-scribble:not([data-done='true']) .btn-scribble__draw { transition: stroke-dashoffset 0.16s linear; }
.btn-scribble:active svg { transform: translateY(1px); }
@media (prefers-reduced-motion: reduce) {
.btn-scribble *, .btn-scribble[data-done='true'] * { transition: none !important; }
}
`;
export function ScribbleButton() {
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
const noise = useRef<SVGFETurbulenceElement>(null);
const root = useRef<HTMLButtonElement>(null);
const [done, setDone] = useState(false);
const timer = useRef(0);
// The boil: a new noise seed eight times a second, the rhythm of a line redrawn by hand.
useEffect(() => {
const el = root.current;
if (!el || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let seed = 0;
let beat = 0;
const start = () => {
beat = window.setInterval(() => {
seed = (seed + 1) % 4;
noise.current?.setAttribute('seed', String(seed + 2));
}, 125);
};
const io = new IntersectionObserver(([entry]) => {
window.clearInterval(beat);
if (entry.isIntersecting) start();
});
io.observe(el);
return () => {
io.disconnect();
window.clearInterval(beat);
window.clearTimeout(timer.current);
};
}, []);
const press = () => {
if (done) return;
setDone(true);
timer.current = window.setTimeout(() => setDone(false), 2900);
};
return (
<>
<style>
{css}
</style>
<button ref={root} type="button" className="btn-scribble" aria-label={done ? 'You are in' : 'Sign up'} data-done={done} onClick={press}>
<svg viewBox="0 0 240 176" aria-hidden="true">
<filter id={uid} x="-6%" y="-6%" width="112%" height="112%">
<feTurbulence ref={noise} type="fractalNoise" baseFrequency="0.016" numOctaves="2" seed="2" />
<feDisplacementMap in="SourceGraphic" scale="5" />
</filter>
<g filter={`url(#${uid})`}>
<path className="btn-scribble__hatch" d={HATCH} pathLength={1} />
<path className="btn-scribble__line" strokeWidth="2.6" d="M24 54 C76 49 148 57 212 51 C215 76 210 108 214 130 C150 134 78 128 22 133 C18 108 25 80 20 50" />
<path className="btn-scribble__line" strokeWidth="1.3" d="M18 58 C84 54 150 60 217 55 M209 47 C212 80 216 104 210 135 M216 127 C150 138 90 126 18 137" />
<text className="btn-scribble__word" x="118" y="104">
Sign up
</text>
<path
className="btn-scribble__line btn-scribble__draw btn-scribble__strike"
strokeWidth="3"
pathLength={1}
d="M60 92 C90 84 118 98 176 86 C150 96 104 98 64 100 C96 94 140 102 180 94"
/>
<text className="btn-scribble__reply" x="66" y="27" transform="rotate(-4 66 27)">
You’re in!
</text>
<path className="btn-scribble__line btn-scribble__draw btn-scribble__arrow" strokeWidth="2.2" pathLength={1} d="M46 16 C30 20 28 36 40 47 M31 41 L40 47 L42 37" />
</g>
</svg>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnScribble(el) on the mounted element instead.
<button type="button" class="btn-scribble" aria-label="Sign up" data-done="false">
<svg viewBox="0 0 240 176" aria-hidden="true">
<filter id="btn-scribble-boil" x="-6%" y="-6%" width="112%" height="112%">
<feTurbulence type="fractalNoise" baseFrequency="0.016" numOctaves="2" seed="2"></feTurbulence>
<feDisplacementMap in="SourceGraphic" scale="5"></feDisplacementMap>
</filter>
<g filter="url(#btn-scribble-boil)">
<path class="btn-scribble__hatch" d="M26 118 L39.0 60.9 L31.0 127.8 L55.0 63.0 L47.0 123.3 L71.0 56.3 L63.0 120.9 L87.0 60.5 L79.0 127.7 L103.0 63.2 L95.0 123.6 L119.0 56.4 L111.0 120.7 L135.0 60.2 L127.0 127.5 L151.0 63.4 L143.0 123.9 L167.0 56.5 L159.0 120.5 L183.0 59.9 L175.0 127.4 L199.0 63.6" pathLength="1"></path>
<path class="btn-scribble__line" stroke-width="2.6" d="M24 54 C76 49 148 57 212 51 C215 76 210 108 214 130 C150 134 78 128 22 133 C18 108 25 80 20 50"></path>
<path class="btn-scribble__line" stroke-width="1.3" d="M18 58 C84 54 150 60 217 55 M209 47 C212 80 216 104 210 135 M216 127 C150 138 90 126 18 137"></path>
<text class="btn-scribble__word" x="118" y="104">Sign up</text>
<path class="btn-scribble__line btn-scribble__draw btn-scribble__strike" stroke-width="3" pathLength="1" d="M60 92 C90 84 118 98 176 86 C150 96 104 98 64 100 C96 94 140 102 180 94"></path>
<text class="btn-scribble__reply" x="66" y="27" transform="rotate(-4 66 27)">You’re in!</text>
<path class="btn-scribble__line btn-scribble__draw btn-scribble__arrow" stroke-width="2.2" pathLength="1" d="M46 16 C30 20 28 36 40 47 M31 41 L40 47 L42 37"></path>
</g>
</svg>
</button>.btn-scribble { flex: none; display: block; width: 240px; height: 176px; padding: 0; border: 0; border-radius: 12px; background: none; color: light-dark(#373a40, #f1f2f3); cursor: pointer; }
.btn-scribble:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.btn-scribble svg { display: block; width: 100%; height: 100%; overflow: visible; }
.btn-scribble__line { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; }
.btn-scribble__hatch { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 5.5; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 0.3s ease-in; }
.btn-scribble:hover .btn-scribble__hatch, .btn-scribble:focus-visible .btn-scribble__hatch { stroke-dashoffset: 0; transition: stroke-dashoffset 0.8s cubic-bezier(0.3, 0.1, 0.6, 1); }
.btn-scribble text { fill: currentColor; font-family: 'Bradley Hand', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive; font-weight: 700; }
.btn-scribble__word { font-size: 36px; text-anchor: middle; }
.btn-scribble__draw { stroke-dasharray: 1; stroke-dashoffset: 1; }
.btn-scribble[data-done='true'] .btn-scribble__strike { stroke-dashoffset: 0; transition: stroke-dashoffset 0.34s linear; }
.btn-scribble[data-done='true'] .btn-scribble__arrow { stroke-dashoffset: 0; transition: stroke-dashoffset 0.22s linear 0.86s; }
.btn-scribble__reply { font-size: 25px; clip-path: inset(-12px 100% -12px -4px); }
.btn-scribble[data-done='true'] .btn-scribble__reply { clip-path: inset(-12px -8px -12px -4px); transition: clip-path 0.56s steps(10) 0.36s; }
.btn-scribble:not([data-done='true']) .btn-scribble__draw { transition: stroke-dashoffset 0.16s linear; }
.btn-scribble:active svg { transform: translateY(1px); }
@media (prefers-reduced-motion: reduce) {
.btn-scribble *, .btn-scribble[data-done='true'] * { transition: none !important; }
}function initBtnScribble(root) {
var noise = root.querySelector('feTurbulence');
var seed = 0;
var beat = 0;
var timer = 0;
var done = false;
// The boil: a new noise seed eight times a second, the rhythm of a line redrawn by hand.
var io = new IntersectionObserver(function (entries) {
clearInterval(beat);
if (entries[0].isIntersecting && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
beat = setInterval(function () {
seed = (seed + 1) % 4;
noise.setAttribute('seed', String(seed + 2));
}, 125);
}
});
function set(next) {
done = next;
root.setAttribute('data-done', String(next));
root.setAttribute('aria-label', next ? 'You are in' : 'Sign up');
}
function onClick() {
if (done) return;
set(true);
timer = setTimeout(function () {
set(false);
}, 2900);
}
io.observe(root);
root.addEventListener('click', onClick);
return function () {
io.disconnect();
clearInterval(beat);
clearTimeout(timer);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-scribble').forEach(initBtnScribble);Goal: Create a "Scribble" button.
Visual behavior: Drawn by hand: the line boils like a pen animation, a marker hatches in the box on hover, and pressing scratches the word out and scrawls an answer above it.
Technique:
- The whole drawing passes through an SVG turbulence and displacement filter, and an interval changes the noise seed eight times a second: that is what makes the line boil.
- The hatching and the strike-through are paths drawn in by their dash offset. The reply is revealed by a clip-path with steps() timing, so it looks written rather than wiped.
Accessibility: A real button whose aria-label changes with its state; the drawing is aria-hidden. Under prefers-reduced-motion the line holds still and everything appears at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Scribble" button.
Visual behavior: Drawn by hand: the line boils like a pen animation, a marker hatches in the box on hover, and pressing scratches the word out and scrawls an answer above it.
Technique:
- The whole drawing passes through an SVG turbulence and displacement filter, and an interval changes the noise seed eight times a second: that is what makes the line boil.
- The hatching and the strike-through are paths drawn in by their dash offset. The reply is revealed by a clip-path with steps() timing, so it looks written rather than wiped.
Accessibility: A real button whose aria-label changes with its state; the drawing is aria-hidden. Under prefers-reduced-motion the line holds still and everything appears at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Drawn by hand: the line boils like a pen animation, a marker hatches in the box on hover, and pressing scratches the word out and scrawls an answer above it.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, click, draw, state change
- Techniques
- svg filter, stroke dashoffset, interval, clip path, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real button whose aria-label changes with its state; the drawing is aria-hidden. Under prefers-reduced-motion the line holds still and everything appears at once.