Honk

No dependencies
Code format
React + Tailwind
import { useEffect, useId, useRef, useState } from 'react';

// A hand-cut starburst: fourteen uneven spikes around the point it bursts from.
const STAR = (() => {
  const outer = [70, 58, 74, 60, 68, 76, 58, 72, 62, 74, 58, 70, 64, 76];
  const pts: string[] = [];
  outer.forEach((r, i) => {
    const a = (i / outer.length) * Math.PI * 2 - Math.PI / 2;
    const b = ((i + 0.5) / outer.length) * Math.PI * 2 - Math.PI / 2;
    pts.push(`${(Math.cos(a) * r).toFixed(1)},${(Math.sin(a) * r * 0.76).toFixed(1)}`);
    pts.push(`${(Math.cos(b) * 45).toFixed(1)},${(Math.sin(b) * 33).toFixed(1)}`);
  });
  return pts.join(' ');
})();

const css = `
  .btn-honk { flex: none; --paper: light-dark(#fbfcfd, #f1f2f3); --ink: light-dark(#373a40, #121417); --q: 0; --p: 1; position: relative; display: block; width: 268px; height: 196px; padding: 0; border: 0; border-radius: 12px; background: none; cursor: pointer; touch-action: manipulation; -webkit-user-select: none; user-select: none; }
  .btn-honk:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .btn-honk svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
  .btn-honk__fill { fill: var(--paper); stroke: var(--ink); stroke-width: 3.4; stroke-linejoin: round; }
  .btn-honk__dots { stroke: none; }
  .btn-honk__ink { fill: none; stroke: var(--ink); stroke-width: 2.6; stroke-linecap: round; }
  .btn-honk__bulb { transform-box: fill-box; transform-origin: 100% 50%; transform: scale(calc(1 - 0.42 * var(--q)), calc(1 + 0.2 * var(--q))); transition: transform 0.36s cubic-bezier(0.3, 2.3, 0.5, 1); }
  .btn-honk[data-held='true'] .btn-honk__bulb { transition: transform 0.08s linear; }
  .btn-honk[data-held='true'] .btn-honk__horn { animation: btn-honk-tremble 0.07s linear infinite; }
  @keyframes btn-honk-tremble {
    0%, 100% { transform: translate(-0.6px, 0.4px); }
    50% { transform: translate(0.6px, -0.4px); }
  }
  .btn-honk__caption { position: absolute; left: 10px; top: 14px; padding: 3px 10px 3px; background: var(--paper); box-shadow: 0 0 0 2.5px var(--ink), 3px 3px 0 2.5px var(--ink); color: var(--ink); font: italic 900 16px/1.15 'Arial Black', Impact, system-ui, sans-serif; letter-spacing: 0.02em; transform: rotate(-4deg); transition: transform 0.2s cubic-bezier(0.3, 1.7, 0.5, 1); }
  .btn-honk:hover .btn-honk__caption { transform: rotate(-7deg) scale(1.06); }
  .btn-honk__burst { opacity: 0; transform-box: view-box; transform-origin: 196px 66px; pointer-events: none; }
  .btn-honk__word { fill: var(--ink); font: italic 400 33px Impact, 'Arial Black', system-ui, sans-serif; letter-spacing: 0.02em; text-anchor: middle; }
  .btn-honk__lines { opacity: 0; }
  .btn-honk[data-hit='a'] .btn-honk__burst { animation: btn-honk-burst-a 1.15s linear both; }
  .btn-honk[data-hit='b'] .btn-honk__burst { animation: btn-honk-burst-b 1.15s linear both; }
  .btn-honk[data-hit='a'] { animation: btn-honk-shake-a 0.4s linear; }
  .btn-honk[data-hit='b'] { animation: btn-honk-shake-b 0.4s linear; }
  .btn-honk[data-hit='a'] .btn-honk__lines { animation: btn-honk-lines-a 0.55s steps(4) both; }
  .btn-honk[data-hit='b'] .btn-honk__lines { animation: btn-honk-lines-b 0.55s steps(4) both; }
  @keyframes btn-honk-burst-a {
    0% { opacity: 1; transform: scale(0.15) rotate(-24deg); }
    9% { opacity: 1; transform: scale(calc(var(--p) * 1.28)) rotate(5deg); }
    17% { transform: scale(calc(var(--p) * 0.94)) rotate(-3deg); }
    25%, 74% { opacity: 1; transform: scale(var(--p)) rotate(-2deg); }
    100% { opacity: 0; transform: scale(calc(var(--p) * 1.12)) rotate(-2deg); }
  }
  @keyframes btn-honk-burst-b {
    0% { opacity: 1; transform: scale(0.15) rotate(-24deg); }
    9% { opacity: 1; transform: scale(calc(var(--p) * 1.28)) rotate(5deg); }
    17% { transform: scale(calc(var(--p) * 0.94)) rotate(-3deg); }
    25%, 74% { opacity: 1; transform: scale(var(--p)) rotate(-2deg); }
    100% { opacity: 0; transform: scale(calc(var(--p) * 1.12)) rotate(-2deg); }
  }
  /* The impact frame: two frames in negative, the way a comic hits you with a panel. */
  @keyframes btn-honk-shake-a {
    0%, 7% { filter: invert(1); transform: translate(-5px, 3px) rotate(-1.5deg); }
    8% { filter: none; }
    20% { transform: translate(5px, -3px) rotate(1.2deg); }
    40% { transform: translate(-3px, 2px); }
    60% { transform: translate(2px, -1px); }
    100% { transform: none; }
  }
  @keyframes btn-honk-shake-b {
    0%, 7% { filter: invert(1); transform: translate(-5px, 3px) rotate(-1.5deg); }
    8% { filter: none; }
    20% { transform: translate(5px, -3px) rotate(1.2deg); }
    40% { transform: translate(-3px, 2px); }
    60% { transform: translate(2px, -1px); }
    100% { transform: none; }
  }
  @keyframes btn-honk-lines-a {
    0% { opacity: 1; transform: translateX(-8px); }
    100% { opacity: 0; transform: translateX(14px); }
  }
  @keyframes btn-honk-lines-b {
    0% { opacity: 1; transform: translateX(-8px); }
    100% { opacity: 0; transform: translateX(14px); }
  }
  @media (prefers-reduced-motion: reduce) {
    .btn-honk[data-hit], .btn-honk[data-held='true'] .btn-honk__horn, .btn-honk[data-hit] .btn-honk__lines { animation: none; }
  }
`;

export function HonkButton() {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
  const root = useRef<HTMLButtonElement>(null);
  const [held, setHeld] = useState(false);
  const [hit, setHit] = useState('');
  const charge = useRef({ raf: 0, from: 0, q: 0, pointer: false });

  useEffect(() => () => cancelAnimationFrame(charge.current.raf), []);

  const setQ = (q: number) => {
    charge.current.q = q;
    root.current?.style.setProperty('--q', q.toFixed(3));
  };
  const squeeze = () => {
    const c = charge.current;
    c.pointer = true;
    c.from = performance.now();
    setHeld(true);
    const grow = (t: number) => {
      setQ(Math.min(1, (t - c.from) / 800));
      c.raf = requestAnimationFrame(grow);
    };
    c.raf = requestAnimationFrame(grow);
  };
  const fire = (power: number) => {
    root.current?.style.setProperty('--p', power.toFixed(2));
    setHit((h) => (h === 'a' ? 'b' : 'a'));
  };
  const release = () => {
    const c = charge.current;
    if (!c.pointer) return;
    c.pointer = false;
    cancelAnimationFrame(c.raf);
    setHeld(false);
    fire(0.72 + c.q * 0.5);
    setQ(0);
  };

  return (
    <>
      <style>
        {css}
      </style>
      <button
        ref={root}
        type="button"
        className="btn-honk"
        aria-label="Nudge them"
        data-held={held}
        data-hit={hit || undefined}
        onPointerDown={squeeze}
        onPointerUp={release}
        onPointerCancel={release}
        onPointerLeave={release}
        // Keyboard presses have no squeeze to measure: they land a medium honk.
        onClick={(e) => {
          if (e.detail === 0) fire(0.95);
        }}
      >
        <svg viewBox="0 0 268 196" aria-hidden="true">
          <defs>
            <pattern id={uid} width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(30)">
              <circle cx="2.5" cy="2.5" r="1.25" fill="var(--ink)" />
            </pattern>
          </defs>
          <g className="btn-honk__horn" transform="rotate(-11 70 128)">
            <path className="btn-honk__fill" d="M94 114 L130 110 C152 104 170 88 182 74 L182 172 C170 158 152 142 130 136 L94 132 Z" />
            <path className="btn-honk__dots" d="M94 114 L130 110 C152 104 170 88 182 74 L182 172 C170 158 152 142 130 136 L94 132 Z" fill={`url(#${uid})`} opacity="0.42" />
            <path className="btn-honk__ink" d="M104 119 L130 116 C146 112 158 104 168 96" />
            <ellipse className="btn-honk__fill" cx="182" cy="123" rx="9" ry="49" />
            <ellipse cx="182" cy="123" rx="4.5" ry="40" fill="var(--ink)" />
            <rect className="btn-honk__fill" x="84" y="108" width="14" height="30" rx="3" />
            <g className="btn-honk__bulb">
              <path className="btn-honk__fill" d="M86 110 C62 86 16 88 10 123 C16 158 62 160 86 136 Z" />
              <path d="M86 110 C62 86 16 88 10 123 C16 158 62 160 86 136 Z" fill={`url(#${uid})`} />
              <path className="btn-honk__ink" d="M26 110 C32 102 44 99 54 100" />
            </g>
          </g>
          <g className="btn-honk__lines">
            <path className="btn-honk__ink" d="M198 148 L222 158 M192 166 L212 182 M204 128 L232 132" />
          </g>
          <g className="btn-honk__burst">
            <polygon points={STAR} transform="translate(196 66)" className="btn-honk__fill" strokeWidth="4" />
            <text className="btn-honk__word" x="196" y="78" transform="rotate(-6 196 66)">
              HONK!
            </text>
          </g>
        </svg>
        <span className="btn-honk__caption" aria-hidden="true">
          NUDGE
        </span>
      </button>
    </>
  );
}

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

Install with shadcn

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

About this component

A comic-book bulb horn. Hold to squeeze the rubber, let go and a HONK bursts out of the bell with an impact frame. The longer you squeeze, the bigger the burst.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring
Techniques
keyframes, raf, css variables, svg, filter
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A real button named by aria-label; the drawing is aria-hidden. A keyboard press lands a medium honk, as it has no squeeze to measure. Under prefers-reduced-motion it does not shake or flash.

More buttons

See all
Ransom note
Scribble
Shy
Tangram