Chomp
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnChomp(el) on the mounted element instead.
<button type="button" class="btn-chomp" aria-label="Submit" data-phase="idle">
<svg class="btn-chomp__beast" viewBox="0 0 100 100" aria-hidden="true">
<g class="btn-chomp__body">
<path d="M28 20 L22 3 L39 13 Z M72 20 L78 3 L61 13 Z" fill="var(--hide)"></path>
<ellipse cx="33" cy="92" rx="11" ry="6.5" fill="var(--hide)"></ellipse>
<ellipse cx="67" cy="92" rx="11" ry="6.5" fill="var(--hide)"></ellipse>
<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)"></path>
<circle cx="18" cy="62" r="1.8" fill="var(--maw)" opacity="0.28"></circle>
<circle cx="23" cy="70" r="1.4" fill="var(--maw)" opacity="0.28"></circle>
<circle cx="82" cy="62" r="1.8" fill="var(--maw)" opacity="0.28"></circle>
<circle cx="77" cy="70" r="1.4" fill="var(--maw)" opacity="0.28"></circle>
<g class="btn-chomp__mouth">
<ellipse cx="50" cy="66" rx="25" ry="20" fill="var(--maw)"></ellipse>
<ellipse cx="50" cy="78" rx="14" ry="7" fill="var(--hide)"></ellipse>
<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)"></path>
</g>
<path class="btn-chomp__snag" d="M60 66.5 L63 60.5 L66 66 Z" fill="var(--white)"></path>
<g class="btn-chomp__eye">
<circle cx="34" cy="35" r="13.5" fill="var(--white)"></circle>
<circle class="btn-chomp__pupil" cx="34" cy="35" r="6.5" fill="var(--maw)" style="transform:translate(5px,1px)"></circle>
</g>
<g class="btn-chomp__eye">
<circle cx="66" cy="35" r="13.5" fill="var(--white)"></circle>
<circle class="btn-chomp__pupil" cx="66" cy="35" r="6.5" fill="var(--maw)" style="transform:translate(5px,1px)"></circle>
</g>
</g>
</svg>
<span class="btn-chomp__word" aria-hidden="true">
<span class="btn-chomp__char" style="--i:0">S</span>
<span class="btn-chomp__char" style="--i:1">u</span>
<span class="btn-chomp__char" style="--i:2">b</span>
<span class="btn-chomp__char" style="--i:3">m</span>
<span class="btn-chomp__char" style="--i:4">i</span>
<span class="btn-chomp__char" style="--i:5">t</span>
</span>
<span class="btn-chomp__burp" aria-hidden="true">Yum!</span>
</button>.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; }
}function initBtnChomp(root) {
var phase = 'idle';
var timers = [];
var chars = root.querySelectorAll('.btn-chomp__char');
var pupils = root.querySelectorAll('.btn-chomp__pupil');
var mouth = root.querySelector('.btn-chomp__mouth');
function set(next) {
phase = next;
root.setAttribute('data-phase', next);
}
// The pupils follow the cursor anywhere on the page, or the word when it is eating.
function look(e) {
if (phase === 'eat') return;
pupils.forEach(function (p) {
var box = p.parentNode.getBoundingClientRect();
var dx = e.clientX - (box.left + box.width / 2);
var dy = e.clientY - (box.top + box.height / 2);
var d = Math.hypot(dx, dy) || 1;
var reach = Math.min(1, d / 60) * 5.5;
p.style.transform = 'translate(' + ((dx / d) * reach).toFixed(2) + 'px,' + ((dy / d) * reach).toFixed(2) + 'px)';
});
}
function onClick() {
if (phase !== 'idle') return;
// Each letter flies to wherever the mouth is right now.
var m = mouth.getBoundingClientRect();
var mx = m.left + m.width / 2;
var my = m.top + m.height / 2;
chars.forEach(function (c) {
var 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');
});
pupils.forEach(function (p) {
p.style.transform = 'translate(5px,1px)';
});
set('eat');
timers = [[860, 'chew'], [1480, 'burp'], [2380, 'back'], [2950, 'idle']].map(function (step) {
return setTimeout(function () {
set(step[1]);
}, step[0]);
});
}
window.addEventListener('pointermove', look, { passive: true });
root.addEventListener('click', onClick);
return function () {
timers.forEach(clearTimeout);
window.removeEventListener('pointermove', look);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-chomp').forEach(initBtnChomp);Goal: Create a "Chomp" button.
Visual behavior: 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.
Technique:
- Pointer position sets a transform on each pupil, so the eyes follow the cursor anywhere on the page. A long looping keyframe blinks them.
- A press measures the gap from each letter to the mouth, stores it in CSS variables and plays one suck keyframe with a stagger, nearest letter first. Timers then step a data-phase through chew, burp and back.
- The mouth is one group scaled on Y: nearly closed it reads as a line, open it shows the fangs and tongue.
Accessibility: 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.
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 "Chomp" button.
Visual behavior: 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.
Technique:
- Pointer position sets a transform on each pupil, so the eyes follow the cursor anywhere on the page. A long looping keyframe blinks them.
- A press measures the gap from each letter to the mouth, stores it in CSS variables and plays one suck keyframe with a stagger, nearest letter first. Timers then step a data-phase through chew, burp and back.
- The mouth is one group scaled on Y: nearly closed it reads as a line, open it shows the fangs and tongue.
Accessibility: 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.
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
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.