Shy

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

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

Install with shadcn

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

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.

More buttons

See all
Tangram
Receipt
Stitch
Bell