Chomp

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

const WORD = 'Submit';
type Phase = 'idle' | 'eat' | 'chew' | 'burp' | 'back';

const css = `
  .btn-chomp { flex: none; --hide: light-dark(#50535b, #7d8087); --white: light-dark(#fbfcfd, #f1f2f3); --maw: light-dark(#373a40, #121417); position: relative; display: flex; align-items: flex-end; gap: 4px; padding: 26px 6px 6px; border: 0; border-radius: 14px; background: none; color: light-dark(#373a40, #f1f2f3); cursor: pointer; }
  .btn-chomp:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .btn-chomp__beast { display: block; flex: none; width: 112px; height: 112px; overflow: visible; transform-origin: 50% 96%; transition: transform 0.2s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .btn-chomp:hover .btn-chomp__beast { transform: rotate(2.5deg); }
  .btn-chomp[data-phase='eat'] .btn-chomp__beast { transform: rotate(7deg) scale(1.07); }
  .btn-chomp__body { transform-box: fill-box; transform-origin: 50% 100%; animation: btn-chomp-breathe 2.8s ease-in-out infinite; }
  @keyframes btn-chomp-breathe {
    50% { transform: scale(1.02, 0.98); }
  }
  .btn-chomp[data-phase='chew'] .btn-chomp__body { animation: btn-chomp-chew 0.2s ease-in-out 3; }
  @keyframes btn-chomp-chew {
    0%, 100% { transform: none; }
    30% { transform: scale(1.1, 0.88); }
    70% { transform: scale(0.95, 1.06); }
  }
  .btn-chomp[data-phase='burp'] .btn-chomp__body { animation: btn-chomp-burp 0.5s cubic-bezier(0.3, 1.6, 0.5, 1); }
  @keyframes btn-chomp-burp {
    30% { transform: scale(0.92, 1.12) translateY(-5px); }
  }
  .btn-chomp__eye { transform-box: fill-box; transform-origin: center; animation: btn-chomp-blink 4.4s linear infinite; }
  @keyframes btn-chomp-blink {
    0%, 95%, 100% { transform: none; }
    97.5% { transform: scaleY(0.08); }
  }
  .btn-chomp[data-phase='chew'] .btn-chomp__eye { animation: btn-chomp-squint 0.6s steps(1); transform: scaleY(0.36); }
  @keyframes btn-chomp-squint {
    0%, 100% { transform: scaleY(0.36); }
  }
  .btn-chomp__mouth { transform-box: fill-box; transform-origin: 50% 40%; transform: scaleY(0.11); transition: transform 0.16s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .btn-chomp:hover .btn-chomp__mouth { transform: scaleY(0.22); }
  .btn-chomp[data-phase='eat'] .btn-chomp__mouth { transform: scale(1.1, 1.1); }
  .btn-chomp[data-phase='chew'] .btn-chomp__mouth { transform: scaleY(0.11); }
  .btn-chomp[data-phase='burp'] .btn-chomp__mouth { transform: scale(0.55, 0.62); }
  .btn-chomp__snag { transition: opacity 0.12s; }
  .btn-chomp[data-phase='eat'] .btn-chomp__snag, .btn-chomp[data-phase='burp'] .btn-chomp__snag { opacity: 0; }
  .btn-chomp__word { display: flex; margin-bottom: 16px; font-size: 30px; font-weight: 800; letter-spacing: -0.02em; line-height: 1; }
  .btn-chomp__char { display: block; white-space: pre; }
  .btn-chomp[data-phase='eat'] .btn-chomp__char { animation: btn-chomp-suck 0.38s cubic-bezier(0.5, 0, 0.9, 0.5) calc(100ms + var(--i) * 70ms) forwards; }
  @keyframes btn-chomp-suck {
    60% { opacity: 1; }
    to { transform: translate(var(--dx), var(--dy)) rotate(-70deg) scale(0.25); opacity: 0; }
  }
  .btn-chomp[data-phase='chew'] .btn-chomp__char, .btn-chomp[data-phase='burp'] .btn-chomp__char { opacity: 0; }
  .btn-chomp[data-phase='back'] .btn-chomp__char { animation: btn-chomp-pop 0.34s cubic-bezier(0.3, 1.7, 0.5, 1) calc(var(--i) * 45ms) backwards; }
  @keyframes btn-chomp-pop {
    from { transform: translateY(14px) scale(0); opacity: 0; }
  }
  .btn-chomp__burp { position: absolute; left: 92px; top: 2px; padding: 3px 10px; border-radius: 12px 12px 12px 2px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font-size: 13px; font-weight: 700; opacity: 0; transform: scale(0); transform-origin: 0 100%; }
  .btn-chomp[data-phase='burp'] .btn-chomp__burp { animation: btn-chomp-bubble 0.9s cubic-bezier(0.3, 1.6, 0.5, 1) both; }
  @keyframes btn-chomp-bubble {
    0% { opacity: 0; transform: scale(0); }
    25%, 85% { opacity: 1; transform: none; }
    100% { opacity: 0; transform: translateY(-6px); }
  }
  @media (prefers-reduced-motion: reduce) {
    .btn-chomp__body, .btn-chomp__eye, .btn-chomp[data-phase] .btn-chomp__body, .btn-chomp[data-phase] .btn-chomp__eye { animation: none; }
    .btn-chomp[data-phase='eat'] .btn-chomp__char { animation: none; opacity: 0; }
    .btn-chomp[data-phase='back'] .btn-chomp__char { animation: none; }
  }
`;

export function ChompButton() {
  const root = useRef<HTMLButtonElement>(null);
  const [phase, setPhase] = useState<Phase>('idle');
  const timers = useRef<number[]>([]);
  const phaseRef = useRef<Phase>('idle');
  phaseRef.current = phase;

  // The pupils follow the cursor anywhere on the page, or the word when it is eating.
  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const pupils = [...el.querySelectorAll<SVGElement>('.btn-chomp__pupil')];
    const look = (e: PointerEvent) => {
      if (phaseRef.current === 'eat') return;
      for (const p of pupils) {
        const box = (p.parentNode as SVGElement).getBoundingClientRect();
        const dx = e.clientX - (box.left + box.width / 2);
        const dy = e.clientY - (box.top + box.height / 2);
        const d = Math.hypot(dx, dy) || 1;
        const reach = Math.min(1, d / 60) * 5.5;
        p.style.transform = `translate(${((dx / d) * reach).toFixed(2)}px,${((dy / d) * reach).toFixed(2)}px)`;
      }
    };
    window.addEventListener('pointermove', look, { passive: true });
    return () => {
      window.removeEventListener('pointermove', look);
      timers.current.forEach((t) => window.clearTimeout(t));
    };
  }, []);

  const press = () => {
    const el = root.current;
    if (!el || phase !== 'idle') return;
    const mouth = el.querySelector('.btn-chomp__mouth')!.getBoundingClientRect();
    const mx = mouth.left + mouth.width / 2;
    const my = mouth.top + mouth.height / 2;
    el.querySelectorAll<HTMLElement>('.btn-chomp__char').forEach((c) => {
      const b = c.getBoundingClientRect();
      c.style.setProperty('--dx', `${(mx - b.left - b.width / 2).toFixed(1)}px`);
      c.style.setProperty('--dy', `${(my - b.top - b.height / 2).toFixed(1)}px`);
    });
    el.querySelectorAll<SVGElement>('.btn-chomp__pupil').forEach((p) => {
      p.style.transform = 'translate(5px,1px)';
    });
    const steps: [number, Phase][] = [
      [860, 'chew'],
      [1480, 'burp'],
      [2380, 'back'],
      [2950, 'idle'],
    ];
    setPhase('eat');
    timers.current = steps.map(([ms, next]) => window.setTimeout(() => setPhase(next), ms));
  };

  return (
    <>
      <style>
        {css}
      </style>
      <button ref={root} type="button" className="btn-chomp" aria-label={WORD} data-phase={phase} onClick={press}>
        <svg className="btn-chomp__beast" viewBox="0 0 100 100" aria-hidden="true">
          <g className="btn-chomp__body">
            <path d="M28 20 L22 3 L39 13 Z M72 20 L78 3 L61 13 Z" fill="var(--hide)" />
            <ellipse cx="33" cy="92" rx="11" ry="6.5" fill="var(--hide)" />
            <ellipse cx="67" cy="92" rx="11" ry="6.5" fill="var(--hide)" />
            <path d="M10 60 C8 26 28 10 50 10 C72 10 92 26 90 60 C89 84 72 94 50 94 C28 94 11 84 10 60 Z" fill="var(--hide)" />
            <circle cx="18" cy="62" r="1.8" fill="var(--maw)" opacity="0.28" />
            <circle cx="23" cy="70" r="1.4" fill="var(--maw)" opacity="0.28" />
            <circle cx="82" cy="62" r="1.8" fill="var(--maw)" opacity="0.28" />
            <circle cx="77" cy="70" r="1.4" fill="var(--maw)" opacity="0.28" />
            <g className="btn-chomp__mouth">
              <ellipse cx="50" cy="66" rx="25" ry="20" fill="var(--maw)" />
              <ellipse cx="50" cy="78" rx="14" ry="7" fill="var(--hide)" />
              <path d="M34 50 L38.5 62 L43.5 48.5 Z M56.5 48.5 L61.5 62 L66 50 Z" fill="var(--white)" />
            </g>
            <path className="btn-chomp__snag" d="M60 66.5 L63 60.5 L66 66 Z" fill="var(--white)" />
            <g className="btn-chomp__eye">
              <circle cx="34" cy="35" r="13.5" fill="var(--white)" />
              <circle className="btn-chomp__pupil" cx="34" cy="35" r="6.5" fill="var(--maw)" style={{ transform: 'translate(5px,1px)' }} />
            </g>
            <g className="btn-chomp__eye">
              <circle cx="66" cy="35" r="13.5" fill="var(--white)" />
              <circle className="btn-chomp__pupil" cx="66" cy="35" r="6.5" fill="var(--maw)" style={{ transform: 'translate(5px,1px)' }} />
            </g>
          </g>
        </svg>
        <span className="btn-chomp__word" aria-hidden="true">
          {[...WORD].map((ch, i) => (
            <span key={i} className="btn-chomp__char" style={{ '--i': i } as CSSProperties}>
              {ch}
            </span>
          ))}
        </span>
        <span className="btn-chomp__burp" aria-hidden="true">
          Yum!
        </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-chomp
Source on GitHub

About this component

A little monster sits next to its own label and watches your cursor. Press and it opens wide, sucks the word in letter by letter, chews, says Yum and spits the word back out.

Dependencies
None
Difficulty
Intermediate
Triggers
click, mouse tracking, state change, delay stagger
Techniques
keyframes, state machine, event position, css variables, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A real button named by aria-label; the drawing, the letters and the speech bubble are aria-hidden. Under prefers-reduced-motion the letters simply hide and return, and the monster stops breathing and blinking.

More buttons

See all
Dandelion
Doorway
Footlight
Gumball