Honk
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnHonk(el) on the mounted element instead.
<button type="button" class="btn-honk" aria-label="Nudge them" data-held="false">
<svg viewBox="0 0 268 196" aria-hidden="true">
<defs>
<pattern id="btn-honk-dots" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(30)">
<circle cx="2.5" cy="2.5" r="1.25" fill="var(--ink)"></circle>
</pattern>
</defs>
<g class="btn-honk__horn" transform="rotate(-11 70 128)">
<path class="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>
<path class="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(#btn-honk-dots)" opacity="0.42"></path>
<path class="btn-honk__ink" d="M104 119 L130 116 C146 112 158 104 168 96"></path>
<ellipse class="btn-honk__fill" cx="182" cy="123" rx="9" ry="49"></ellipse>
<ellipse cx="182" cy="123" rx="4.5" ry="40" fill="var(--ink)"></ellipse>
<rect class="btn-honk__fill" x="84" y="108" width="14" height="30" rx="3"></rect>
<g class="btn-honk__bulb">
<path class="btn-honk__fill" d="M86 110 C62 86 16 88 10 123 C16 158 62 160 86 136 Z"></path>
<path d="M86 110 C62 86 16 88 10 123 C16 158 62 160 86 136 Z" fill="url(#btn-honk-dots)"></path>
<path class="btn-honk__ink" d="M26 110 C32 102 44 99 54 100"></path>
</g>
</g>
<g class="btn-honk__lines">
<path class="btn-honk__ink" d="M198 148 L222 158 M192 166 L212 182 M204 128 L232 132"></path>
</g>
<g class="btn-honk__burst">
<polygon points="0.0,-53.2 10.0,-32.2 25.2,-39.7 28.1,-25.8 57.9,-35.1 40.5,-14.3 58.5,-10.1 45.0,0.0 66.3,11.5 40.5,14.3 59.4,36.0 28.1,25.8 25.2,39.7 10.0,32.2 0.0,54.7 -10.0,32.2 -26.9,42.5 -28.1,25.8 -57.9,35.1 -40.5,14.3 -56.5,9.8 -45.0,0.0 -68.2,-11.8 -40.5,-14.3 -50.0,-30.3 -28.1,-25.8 -33.0,-52.0 -10.0,-32.2" transform="translate(196 66)" class="btn-honk__fill" stroke-width="4"></polygon>
<text class="btn-honk__word" x="196" y="78" transform="rotate(-6 196 66)">HONK!</text>
</g>
</svg>
<span class="btn-honk__caption" aria-hidden="true">NUDGE</span>
</button>.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; }
}function initBtnHonk(root) {
var raf = 0;
var from = 0;
var q = 0;
var holding = false;
var hit = 'b';
function setQ(v) {
q = v;
root.style.setProperty('--q', v.toFixed(3));
}
function fire(power) {
root.style.setProperty('--p', power.toFixed(2));
hit = hit === 'a' ? 'b' : 'a';
root.setAttribute('data-hit', hit);
}
function grow(t) {
setQ(Math.min(1, (t - from) / 800));
raf = requestAnimationFrame(grow);
}
function onDown() {
holding = true;
from = performance.now();
root.setAttribute('data-held', 'true');
raf = requestAnimationFrame(grow);
}
// Let go: the longer the squeeze, the bigger the burst.
function onUp() {
if (!holding) return;
holding = false;
cancelAnimationFrame(raf);
root.setAttribute('data-held', 'false');
fire(0.72 + q * 0.5);
setQ(0);
}
// Keyboard presses have no squeeze to measure: they land a medium honk.
function onClick(e) {
if (e.detail === 0) fire(0.95);
}
root.addEventListener('pointerdown', onDown);
root.addEventListener('pointerup', onUp);
root.addEventListener('pointercancel', onUp);
root.addEventListener('pointerleave', onUp);
root.addEventListener('click', onClick);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointerdown', onDown);
root.removeEventListener('pointerup', onUp);
root.removeEventListener('pointercancel', onUp);
root.removeEventListener('pointerleave', onUp);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-honk').forEach(initBtnHonk);Goal: Create a "Honk" button.
Visual behavior: 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.
Technique:
- Holding raises a --q variable from 0 to 1, which squashes the bulb from its neck. Letting go sets a --p variable for the size of the burst and springs the bulb back.
- The burst is a star polygon that overshoots and settles. The whole button shakes and spends its first frames under filter: invert(1), the impact frame comics use. Halftone shading is an SVG pattern.
- Two identical keyframes are swapped so the burst restarts on every honk.
Accessibility: 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.
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 "Honk" button.
Visual behavior: 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.
Technique:
- Holding raises a --q variable from 0 to 1, which squashes the bulb from its neck. Letting go sets a --p variable for the size of the burst and springs the bulb back.
- The burst is a star polygon that overshoots and settles. The whole button shakes and spends its first frames under filter: invert(1), the impact frame comics use. Halftone shading is an SVG pattern.
- Two identical keyframes are swapped so the burst restarts on every honk.
Accessibility: 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.
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 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.