Scribble

No dependencies
Code format
React + Tailwind
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&rsquo;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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/button-scribble
Source on GitHub

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.

More buttons

See all
Shy
Tangram
Receipt
Stitch