Shy
import { useEffect, useRef, useState } from 'react';
const LINES = ['Delete account', 'Are you sure?', 'Please don’t', 'Okay, fine'];
const SPOTS = [
[0, 0],
[-46, -66],
[46, -58],
[-48, 62],
[44, 68],
];
const W = 140;
const H = 40;
const css = `
.btn-shy { flex: none; position: relative; width: 240px; height: 200px; }
.btn-shy__pill { position: absolute; left: 50%; top: 50%; width: ${W}px; height: ${H}px; margin: ${-H / 2}px 0 0 ${-W / 2}px; padding: 0; border: 0; border-radius: 999px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; will-change: transform; }
.btn-shy__pill:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-shy__text { display: block; }
.btn-shy__pill[data-near='true'] .btn-shy__text { animation: btn-shy-tremble 0.09s linear infinite; }
@keyframes btn-shy-tremble {
0%, 100% { transform: translateX(-0.6px); }
50% { transform: translateX(0.6px); }
}
.btn-shy__pill[data-given='true'] { background: light-dark(#50535b, #c6c8cd); }
.btn-shy__pill[data-gone='true'] { animation: btn-shy-gone 0.42s cubic-bezier(0.5, 0, 0.8, 0.4) forwards; }
@keyframes btn-shy-gone {
30% { scale: 1.08 0.8; }
to { scale: 0; opacity: 0; }
}
.btn-shy__pill[data-back='true'] { animation: btn-shy-back 0.4s cubic-bezier(0.3, 1.7, 0.5, 1) backwards; }
@keyframes btn-shy-back {
from { scale: 0; opacity: 0; }
}
.btn-shy__sweat { position: absolute; right: 8px; top: -9px; width: 7px; height: 10px; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%; background: light-dark(#5f626a, #a7abb2); opacity: 0; animation: btn-shy-sweat 0.6s ease-out; }
@keyframes btn-shy-sweat {
0% { opacity: 0; transform: translate(0, 6px) scale(0.4); }
25% { opacity: 1; transform: translate(6px, -6px); }
100% { opacity: 0; transform: translate(16px, 14px) scale(0.7); }
}
.btn-shy__dust { position: absolute; left: 50%; top: 50%; width: 0; height: 0; pointer-events: none; }
.btn-shy__dust i { position: absolute; width: 7px; height: 7px; margin: -3.5px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); animation: btn-shy-dust 0.5s ease-out forwards; }
@keyframes btn-shy-dust {
from { opacity: 0.8; transform: none; }
to { opacity: 0; transform: translate(var(--x), var(--y)) scale(0.2); }
}
.btn-shy__gone { position: absolute; left: 0; right: 0; top: 50%; margin-top: -10px; text-align: center; font-size: 13px; color: light-dark(#5f626a, #a7abb2); animation: btn-shy-note 1.3s ease both; pointer-events: none; }
@keyframes btn-shy-note {
0% { opacity: 0; }
20%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn-shy__pill[data-near='true'] .btn-shy__text, .btn-shy__sweat, .btn-shy__dust i, .btn-shy__pill[data-back='true'] { animation: none; }
.btn-shy__sweat { display: none; }
.btn-shy__dust { display: none; }
.btn-shy__pill[data-gone='true'] { animation-duration: 0.01s; }
}
`;
export function ShyButton() {
const root = useRef<HTMLDivElement>(null);
const pill = useRef<HTMLButtonElement>(null);
const [tries, setTries] = useState(0);
const [near, setNear] = useState(false);
const [state, setState] = useState<'here' | 'gone' | 'back'>('here');
const [puff, setPuff] = useState<{ n: number; x: number; y: number } | null>(null);
const live = useRef({ tries: 0, gone: false });
const goHome = useRef(() => {});
useEffect(() => {
const zone = root.current;
const btn = pill.current;
if (!zone || !btn) return;
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
let spot = 0;
let x = 0;
let y = 0;
let vx = 0;
let vy = 0;
let px: number | null = null;
let py = 0;
let last = 0;
let lastDodge = 0;
let isNear = false;
let raf = 0;
let running = false;
const tick = (t: number) => {
const dt = Math.min(0.033, (t - last) / 1000 || 0.016);
last = t;
const given = live.current.tries >= 3;
let gx = SPOTS[spot][0];
let gy = SPOTS[spot][1];
let lean = 0;
if (px !== null && !live.current.gone) {
const dx = x - px;
const dy = y - py;
const d = Math.hypot(dx, dy) || 1;
const insideX = Math.abs(dx) < W / 2 + 24;
const insideY = Math.abs(dy) < H / 2 + 24;
if (d < 120 !== isNear) {
isNear = d < 120;
setNear(isNear);
}
if (!given) {
// Backs away first, so you see it flinch before it bolts.
const away = Math.max(0, 1 - d / 130) * 12;
gx += (dx / d) * away;
gy += (dy / d) * away;
lean = (dx / d) * Math.max(0, 1 - d / 130) * 9;
}
if (!given && insideX && insideY && t - lastDodge > 240) {
lastDodge = t;
let best = spot;
let far = -1;
SPOTS.forEach(([sx, sy], i) => {
const away2 = Math.hypot(sx - px!, sy - py);
if (i !== spot && away2 > far) {
far = away2;
best = i;
}
});
spot = best;
live.current.tries += 1;
setTries(live.current.tries);
setPuff({ n: t, x, y });
if (calm) {
x = SPOTS[best][0];
y = SPOTS[best][1];
}
}
}
vx += ((gx - x) * 210 - vx * 15) * dt;
vy += ((gy - y) * 210 - vy * 15) * dt;
x += vx * dt;
y += vy * dt;
const skew = Math.max(-16, Math.min(16, -vx * 0.035));
const sag = given ? ' translateY(3px) rotate(-2.5deg) scale(1,0.92)' : '';
btn.style.transform = `translate(${x.toFixed(2)}px,${y.toFixed(2)}px) rotate(${lean.toFixed(2)}deg) skewX(${skew.toFixed(2)}deg)${sag}`;
raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && !running) {
running = true;
last = performance.now();
raf = requestAnimationFrame(tick);
} else if (!entry.isIntersecting && running) {
running = false;
cancelAnimationFrame(raf);
}
});
io.observe(zone);
const move = (e: PointerEvent) => {
// Touch and keyboard never get teased: they press it straight away.
if (e.pointerType === 'touch') return;
const box = zone.getBoundingClientRect();
px = e.clientX - box.left - box.width / 2;
py = e.clientY - box.top - box.height / 2;
};
const leave = () => {
px = null;
isNear = false;
setNear(false);
};
zone.addEventListener('pointermove', move);
zone.addEventListener('pointerleave', leave);
goHome.current = () => {
spot = 0;
};
return () => {
io.disconnect();
cancelAnimationFrame(raf);
zone.removeEventListener('pointermove', move);
zone.removeEventListener('pointerleave', leave);
};
}, []);
const press = () => {
if (state === 'gone') return;
live.current.gone = true;
setState('gone');
window.setTimeout(() => {
live.current = { tries: 0, gone: false };
setTries(0);
goHome.current();
setState('back');
}, 1500);
};
return (
<>
<style>
{css}
</style>
<div ref={root} className="btn-shy">
{puff && (
<span key={puff.n} className="btn-shy__dust" style={{ transform: `translate(${puff.x}px,${puff.y + H / 2}px)` }} aria-hidden="true">
{[
[-22, 4],
[-10, 12],
[14, 10],
[24, 2],
].map(([dx, dy]) => (
<i key={dx} style={{ ['--x' as string]: `${dx}px`, ['--y' as string]: `${dy}px` }} />
))}
</span>
)}
{state === 'gone' && (
<span className="btn-shy__gone" role="status">
Account deleted
</span>
)}
<button
ref={pill}
type="button"
className="btn-shy__pill"
aria-label="Delete account"
data-near={near && tries < 3}
data-given={tries >= 3}
data-gone={state === 'gone'}
data-back={state === 'back'}
onClick={press}
>
<span className="btn-shy__text">{LINES[Math.min(tries, 3)]}</span>
{tries > 0 && tries < 3 && <span key={tries} className="btn-shy__sweat" aria-hidden="true" />}
</button>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnShy(el) on the mounted element instead.
<div class="btn-shy">
<button type="button" class="btn-shy__pill" aria-label="Delete account" data-near="false" data-given="false" data-gone="false" data-back="false">
<span class="btn-shy__text">Delete account</span>
</button>
</div>.btn-shy { flex: none; position: relative; width: 240px; height: 200px; }
.btn-shy__pill { position: absolute; left: 50%; top: 50%; width: 140px; height: 40px; margin: -20px 0 0 -70px; padding: 0; border: 0; border-radius: 999px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; will-change: transform; }
.btn-shy__pill:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.btn-shy__text { display: block; }
.btn-shy__pill[data-near='true'] .btn-shy__text { animation: btn-shy-tremble 0.09s linear infinite; }
@keyframes btn-shy-tremble {
0%, 100% { transform: translateX(-0.6px); }
50% { transform: translateX(0.6px); }
}
.btn-shy__pill[data-given='true'] { background: light-dark(#50535b, #c6c8cd); }
.btn-shy__pill[data-gone='true'] { animation: btn-shy-gone 0.42s cubic-bezier(0.5, 0, 0.8, 0.4) forwards; }
@keyframes btn-shy-gone {
30% { scale: 1.08 0.8; }
to { scale: 0; opacity: 0; }
}
.btn-shy__pill[data-back='true'] { animation: btn-shy-back 0.4s cubic-bezier(0.3, 1.7, 0.5, 1) backwards; }
@keyframes btn-shy-back {
from { scale: 0; opacity: 0; }
}
.btn-shy__sweat { position: absolute; right: 8px; top: -9px; width: 7px; height: 10px; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%; background: light-dark(#5f626a, #a7abb2); opacity: 0; animation: btn-shy-sweat 0.6s ease-out; }
@keyframes btn-shy-sweat {
0% { opacity: 0; transform: translate(0, 6px) scale(0.4); }
25% { opacity: 1; transform: translate(6px, -6px); }
100% { opacity: 0; transform: translate(16px, 14px) scale(0.7); }
}
.btn-shy__dust { position: absolute; left: 50%; top: 50%; width: 0; height: 0; pointer-events: none; }
.btn-shy__dust i { position: absolute; width: 7px; height: 7px; margin: -3.5px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); animation: btn-shy-dust 0.5s ease-out forwards; }
@keyframes btn-shy-dust {
from { opacity: 0.8; transform: none; }
to { opacity: 0; transform: translate(var(--x), var(--y)) scale(0.2); }
}
.btn-shy__gone { position: absolute; left: 0; right: 0; top: 50%; margin-top: -10px; text-align: center; font-size: 13px; color: light-dark(#5f626a, #a7abb2); animation: btn-shy-note 1.3s ease both; pointer-events: none; }
@keyframes btn-shy-note {
0% { opacity: 0; }
20%, 75% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn-shy__pill[data-near='true'] .btn-shy__text, .btn-shy__sweat, .btn-shy__dust i, .btn-shy__pill[data-back='true'] { animation: none; }
.btn-shy__sweat { display: none; }
.btn-shy__dust { display: none; }
.btn-shy__pill[data-gone='true'] { animation-duration: 0.01s; }
}function initBtnShy(root) {
var LINES = ['Delete account', 'Are you sure?', 'Please don’t', 'Okay, fine'];
var SPOTS = [[0, 0], [-46, -66], [46, -58], [-48, 62], [44, 68]];
var W = 140;
var H = 40;
var pill = root.querySelector('.btn-shy__pill');
var text = root.querySelector('.btn-shy__text');
var calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
var spot = 0;
var x = 0;
var y = 0;
var vx = 0;
var vy = 0;
var px = null;
var py = 0;
var last = 0;
var lastDodge = 0;
var raf = 0;
var running = false;
var near = false;
var tries = 0;
var gone = false;
var timer = 0;
function setNear(v) {
near = v;
pill.setAttribute('data-near', String(near && tries < 3));
}
function setTries(n) {
tries = n;
text.textContent = LINES[Math.min(n, 3)];
pill.setAttribute('data-given', String(n >= 3));
pill.setAttribute('data-near', String(near && n < 3));
if (n > 0 && n < 3) {
var drop = document.createElement('span');
drop.className = 'btn-shy__sweat';
drop.setAttribute('aria-hidden', 'true');
drop.addEventListener('animationend', function () {
drop.remove();
});
pill.appendChild(drop);
}
}
function puff(atX, atY) {
var dust = document.createElement('span');
dust.className = 'btn-shy__dust';
dust.setAttribute('aria-hidden', 'true');
dust.style.transform = 'translate(' + atX + 'px,' + (atY + H / 2) + 'px)';
[[-22, 4], [-10, 12], [14, 10], [24, 2]].forEach(function (d) {
var bit = document.createElement('i');
bit.style.setProperty('--x', d[0] + 'px');
bit.style.setProperty('--y', d[1] + 'px');
dust.appendChild(bit);
});
root.insertBefore(dust, pill);
setTimeout(function () {
dust.remove();
}, 600);
}
function tick(t) {
var dt = Math.min(0.033, (t - last) / 1000 || 0.016);
last = t;
var given = tries >= 3;
var gx = SPOTS[spot][0];
var gy = SPOTS[spot][1];
var lean = 0;
if (px !== null && !gone) {
var dx = x - px;
var dy = y - py;
var d = Math.hypot(dx, dy) || 1;
if (d < 120 !== near) setNear(d < 120);
if (!given) {
// Backs away first, so you see it flinch before it bolts.
var away = Math.max(0, 1 - d / 130);
gx += (dx / d) * away * 12;
gy += (dy / d) * away * 12;
lean = (dx / d) * away * 9;
}
if (!given && Math.abs(dx) < W / 2 + 24 && Math.abs(dy) < H / 2 + 24 && t - lastDodge > 240) {
lastDodge = t;
var best = spot;
var far = -1;
SPOTS.forEach(function (s, i) {
var gap = Math.hypot(s[0] - px, s[1] - py);
if (i !== spot && gap > far) {
far = gap;
best = i;
}
});
spot = best;
setTries(tries + 1);
puff(x, y);
if (calm) {
x = SPOTS[best][0];
y = SPOTS[best][1];
}
}
}
vx += ((gx - x) * 210 - vx * 15) * dt;
vy += ((gy - y) * 210 - vy * 15) * dt;
x += vx * dt;
y += vy * dt;
var skew = Math.max(-16, Math.min(16, -vx * 0.035));
var sag = given ? ' translateY(3px) rotate(-2.5deg) scale(1,0.92)' : '';
pill.style.transform = 'translate(' + x.toFixed(2) + 'px,' + y.toFixed(2) + 'px) rotate(' + lean.toFixed(2) + 'deg) skewX(' + skew.toFixed(2) + 'deg)' + sag;
raf = requestAnimationFrame(tick);
}
var io = new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting && !running) {
running = true;
last = performance.now();
raf = requestAnimationFrame(tick);
} else if (!entries[0].isIntersecting && running) {
running = false;
cancelAnimationFrame(raf);
}
});
function onMove(e) {
// Touch and keyboard never get teased: they press it straight away.
if (e.pointerType === 'touch') return;
var box = root.getBoundingClientRect();
px = e.clientX - box.left - box.width / 2;
py = e.clientY - box.top - box.height / 2;
}
function onLeave() {
px = null;
setNear(false);
}
function onClick() {
if (gone) return;
gone = true;
pill.setAttribute('data-back', 'false');
pill.setAttribute('data-gone', 'true');
var note = document.createElement('span');
note.className = 'btn-shy__gone';
note.setAttribute('role', 'status');
note.textContent = 'Account deleted';
root.insertBefore(note, pill);
timer = setTimeout(function () {
note.remove();
gone = false;
spot = 0;
setTries(0);
pill.setAttribute('data-gone', 'false');
pill.setAttribute('data-back', 'true');
}, 1500);
}
io.observe(root);
root.addEventListener('pointermove', onMove);
root.addEventListener('pointerleave', onLeave);
pill.addEventListener('click', onClick);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
clearTimeout(timer);
root.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerleave', onLeave);
pill.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-shy').forEach(initBtnShy);Goal: Create a "Shy" button.
Visual behavior: A plain Delete account button that does not want to be pressed: it leans away from your cursor and runs, pleading as it goes, then gives up on the third try and lets you. Keyboard and touch press it straight away.
Technique:
- A spring pulls the button to one of five spots. As a mouse comes near, the spring target shifts away from it, so the button flinches first; once the pointer gets within reach it jumps to the spot furthest from the cursor, skewing with its speed and leaving a puff of dust.
- A try counter changes the text and, on the third, leaves the button slumped and still.
Accessibility: A real button named Delete account throughout. The dodging only answers mouse movement, so the keyboard and touch can press it at once, and the result is read from a role="status" line. Under prefers-reduced-motion it moves between spots without travelling.
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 "Shy" button.
Visual behavior: A plain Delete account button that does not want to be pressed: it leans away from your cursor and runs, pleading as it goes, then gives up on the third try and lets you. Keyboard and touch press it straight away.
Technique:
- A spring pulls the button to one of five spots. As a mouse comes near, the spring target shifts away from it, so the button flinches first; once the pointer gets within reach it jumps to the spot furthest from the cursor, skewing with its speed and leaving a puff of dust.
- A try counter changes the text and, on the third, leaves the button slumped and still.
Accessibility: A real button named Delete account throughout. The dodging only answers mouse movement, so the keyboard and touch can press it at once, and the result is read from a role="status" line. Under prefers-reduced-motion it moves between spots without travelling.
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 plain Delete account button that does not want to be pressed: it leans away from your cursor and runs, pleading as it goes, then gives up on the third try and lets you. Keyboard and touch press it straight away.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- mouse tracking, click, spring, state change
- Techniques
- raf, spring, event position, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real button named Delete account throughout. The dodging only answers mouse movement, so the keyboard and touch can press it at once, and the result is read from a role="status" line. Under prefers-reduced-motion it moves between spots without travelling.