Impeccable

Peter-Tingle-button

A button that feels your pointer coming and tingles, with wavy lines pulsing toward it. Use it for one main action, like a hero call to action.

ButtonPointerNo extra packagesLight + darkMobile
npx shadcn@latest add https://impeccableui.vercel.app/r/peter-tingle-button.json
Bring your pointer slowly toward the button from any side, then rush at it. On a phone, tap it.

Run it in a project set up with shadcn. No other packages needed. Or open and copy the file into components/ui/peter-tingle-button.tsx.

Prompts

Paste one into Claude Code, Cursor or any AI coding tool. Install adds this exact button and asks only where it goes. Blend asks three short questions (where, the button and sense line colors, and how early it senses you), then fits it to your product without changing how it moves.

# Peter-Tingle-button

A 1-file React component from Impeccable. Add it to this project so it works and looks exactly like the source below, in the one place I choose, and change nothing else.

A button that feels the pointer coming: wavy sense lines pulse out of its edge toward it and the button shivers, stronger the closer and faster the pointer comes. When the pointer lands on it, the lines flash out once and it goes calm.

## Before you build: one question

Look through this project first: its landing and hero sections, its main call-to-action buttons and the pages where one action matters most. Then ask me this in one message and wait for my answer:

- **Where should it go?** Suggest the 1–3 buttons it could replace or the spots it fits best (for example the hero's main call to action, or a sign-up or waitlist button). Keep the button's label, link and click handler. I can pick one or name another.
  *Why: the tingle reacts to the pointer from 120px away and draws lines about 40px around the button, so it suits one main action with some room around it, not a toolbar or a dense form.*

Ask nothing else. If I say *skip*, use the best suggestion.

## Keep it as it is

The pill, the wavy sense lines that pulse out of the edge (longest on the pointer's side), the shiver that grows as the pointer comes closer and the flash out and calm when it lands are the design. Keep them exactly as in the source. If something doesn't fit my project, tell me instead of changing it.

- Add only the component file and the one place I picked. If it replaces an existing button, keep that button's label, link, click handler and form wiring. Don't add packages, change other files, or restyle anything.
- Don't add the demo parts from the Impeccable website: the "Sneak up on me" label and the dotted card behind it.

## Fastest path

If this project has a `components.json` (shadcn), run `npx shadcn@latest add https://impeccableui.vercel.app/r/peter-tingle-button.json`.
If the command succeeds, only fix import paths to match this project and skip ahead. If it fails or there is no `components.json`, build it by hand as below.

## Build it

- Stack: React 18 or 19 with TypeScript. No other packages: the sense lines are drawn on one small canvas and the button is plain CSS.
- JavaScript project? Name the file `peter-tingle-button.jsx` and remove the type annotations; nothing else changes.
- Create this file, in this project's usual place for components:
  - `components/ui/peter-tingle-button.tsx`
- Keep these exports and their names: `PeterTingleButton`, `PeterTingleButtonProps`.
- Keep `"use client"` at the top in Next.js App Router projects. In other setups it is harmless.
- Keep the tuning values at the top exactly as written: `PAD`, `GAP`, `TRAVEL`, `PULSE_MS`, `LEN_MIN`, `LEN_MAX`, `AMP`, `WAVE`, `WIGGLE`, `LINE_W`, `SPACING`, `BACK`, `SHIVER`, `TILT`, `RISE_MS`, `FALL_MS`, `RUSH`, `RUSH_SPEED`, `BURST_MS`, `BURST_PUSH`, `FLINCH`, `TAP_MS`, `FOCUS_MS`, `FOCUS_AMT` and the `CSS` block. They are the design.
- Keep the fallbacks: with `prefers-reduced-motion: reduce` the lines only fade in and out (no shiver, no waves, no drift). On touch screens a tap sets off one short tingle all around. Keyboard focus sets off a softer one and shows a ring in the fill color. A disabled button never tingles.

## Use it

```tsx
"use client";
import { PeterTingleButton } from "@/components/ui/peter-tingle-button";

export function HeroAction() {
  return <PeterTingleButton onClick={() => router.push("/signup")}>Get started</PeterTingleButton>;
}
```

- It is a real `<button>` and takes every button prop (`onClick`, `type`, `disabled`, `aria-*`). For a link, put it inside the link or call the router in `onClick`.
- By default the fill is the text color around it, so place it where my normal text color applies. `className` and `style` go on the button itself (44px tall, 22px sides).
- Leave about 40px of free space around it. A parent with hidden overflow closer than that cuts the lines off.

## How the tingle works

Read this before you touch anything, so you know what the code does and why it must stay as it is.

1. **The button.** A 44px pill in the fill color, with a soft top shine and a small drop shadow. The fill is the text color around it unless `color` is set, and the label is black or white, whichever reads, unless `textColor` is set.
2. **Sensing.** It listens to the mouse across the whole page. When the pointer comes within `range` (120px) of the button's edge, a small loop starts. The tingle grows stronger all the way to the edge, and a pointer that comes in fast adds up to 35% more, so a rush feels like a threat and a slow approach like a hunch.
3. **The sense lines.** Short wavy lines sit around the whole edge, one every 24px (or `lines` of them). Each one pulses: it fades in, drifts 9px outward and fades away every 560ms, out of step with its neighbours, while its waves crawl along it. Lines facing the pointer are longer (up to 18px) and brighter; the ones facing away stay at 40%. They are drawn in `senseColor`, or the fill color.
4. **The shiver.** At the same time the button shivers by up to 1.1px and tilts by up to half a degree, growing with the square of the tingle, so it only shows up close in.
5. **Landing on it.** The moment the pointer reaches the button, every line flashes outward once (320ms), the button flinches a tiny bit, and then everything goes still, so the click is always steady. Leaving and coming back starts the tingle again.
6. **Drawing.** The canvas hugs the button with 44px of room on every side and sits behind it. It only draws while something moves, and the loop stops the moment it is calm. All motion runs on elapsed time, so it feels the same on slow phones and fast screens. The colors are read from the page on every frame, so they follow light and dark mode.

## Check

- It type-checks and the page renders with no console errors.
- Moving the mouse toward the button: from about 120px away wavy lines start to pulse out of its edge, longest on the side the pointer comes from, and grow stronger closer in, with a slight shiver near the edge. Rushing at it makes it stronger. When the pointer lands on the button, the lines flash out once and it goes still; a click works as before.
- It looks right in light and dark mode (check both), and the lines and label switch with the theme.
- On a phone, a tap sets off one short tingle all around and the button still works on the first tap, with no sideways scroll.
- With `prefers-reduced-motion: reduce`, the lines only fade in and out, with no shiver, waves or drift.
- Nothing else on the site looks or behaves differently.
- Finish with a short report: the files you added or changed, where it's placed, and every place where you had to do something differently from the source, and why.

## Source

The reference implementation. Copy it as-is, then apply only the changes listed above.

### `components/ui/peter-tingle-button.tsx`

```tsx
"use client";

/**
 * Peter-Tingle-button, from Impeccable.
 *
 * A button with a sixth sense. When the pointer comes near, the button feels it
 * coming: short wavy sense lines start to pulse out of its edge, longest and
 * brightest on the side the pointer is coming from, and the button shivers a
 * little. The closer (and the faster) the pointer comes, the stronger the tingle.
 * The moment the pointer lands on it, the lines flash outward once and everything
 * goes calm, so clicking is always steady.
 *
 * Phones have no pointer to sense, so a tap sets off one short tingle all around.
 * Keyboard focus sets off a softer one.
 *
 * No packages: one small canvas that hugs the button and only draws while it
 * tingles, and one requestAnimationFrame loop that stops when it is calm.
 */

import { forwardRef, useEffect, useImperativeHandle, useRef, type ButtonHTMLAttributes, type CSSProperties } from "react";

export type PeterTingleButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  /** The button's fill. Any CSS color, including var(--…). Default: the text color around it. */
  color?: string;
  /** The label color. Default: black or white, whichever reads best on the fill. */
  textColor?: string;
  /** The color of the sense lines. Default: the same as the fill. */
  senseColor?: string;
  /** Distance in px from the button's edge where the tingle starts. */
  range?: number;
  /** How many sense lines go around the edge. Default: one every 24px of edge. */
  lines?: number;
};

/* ---------- tuning (the design: keep as written) ---------- */

const PAD = 44; // px of room around the button for the lines (the canvas overhang)
const GAP = 4; // px between the edge and where a line starts
const TRAVEL = 9; // px a line drifts outward during one pulse
const PULSE_MS = 560; // one pulse: a line fades in, drifts out and fades away
const LEN_MIN = 4; // px: a line's length when the tingle is faint
const LEN_MAX = 18; // px: a line's length at full tingle
const AMP = 1.6; // px: how far a line waves from side to side
const WAVE = 5.5; // px: the length of one wave
const WIGGLE = 0.018; // how fast the waves crawl along a line, radians per ms
const LINE_W = 1.6; // px: line thickness
const SPACING = 24; // px of edge per line when `lines` is not set
const BACK = 0.4; // how strong the lines are on the side facing away from the pointer
const SHIVER = 1.1; // px the button shivers at full tingle
const TILT = 0.5; // degrees the button tilts at full tingle
const RISE_MS = 90; // how fast the tingle builds
const FALL_MS = 240; // how fast it calms down
const RUSH = 0.35; // extra tingle for a pointer that comes in fast
const RUSH_SPEED = 1600; // px per second that counts as fast
const BURST_MS = 320; // the flash outward when the pointer lands on the button
const BURST_PUSH = 14; // px the lines fly out in that flash
const FLINCH = 0.03; // how much the button shrinks in that flash
const TAP_MS = 650; // the tingle a tap sets off on a touch screen
const FOCUS_MS = 900; // the softer tingle keyboard focus sets off
const FOCUS_AMT = 0.6;

/** Styles that can't be inline: press, focus, disabled and reduced motion. */
const CSS = `
.ipt { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 0; max-width: 100%; height: 44px; padding: 0 22px; border: 0; border-radius: 999px; background: none; color: inherit; font: inherit; font-size: 15px; font-weight: 600; letter-spacing: -0.01em; cursor: pointer; -webkit-tap-highlight-color: transparent; isolation: isolate; }
.ipt-skin, .ipt-label { transform: var(--ipt-t, none); }
.ipt-skin { position: absolute; inset: 0; z-index: -1; border-radius: inherit; background-color: var(--ipt-ink, currentColor);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .16), 0 1px 2px rgb(0 0 0 / .18), 0 6px 14px -8px rgb(0 0 0 / .4);
  transition: scale .18s cubic-bezier(.23,1,.32,1); }
.ipt-label { position: relative; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ipt-text, var(--ipt-auto-text, Canvas)); }
.ipt-sense { position: absolute; left: -${PAD}px; top: -${PAD}px; z-index: -2; pointer-events: none; color: var(--ipt-sense, var(--ipt-ink, currentColor)); }
.ipt:active:not(:disabled) .ipt-skin { scale: .97; }
.ipt:focus-visible { outline: 2px solid var(--ipt-ink, currentColor); outline-offset: 3px; }
.ipt:disabled { opacity: .5; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) { .ipt-skin { transition: none; } .ipt:active:not(:disabled) .ipt-skin { scale: 1; } }`;

/* ---------- small helpers ---------- */

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));

/** Signed distance from a point to a rounded rectangle (negative inside). */
function distTo(px: number, py: number, cx: number, cy: number, hw: number, hh: number, r: number) {
  const qx = Math.abs(px - cx) - (hw - r);
  const qy = Math.abs(py - cy) - (hh - r);
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
}

/** The point `s` px along a rounded rectangle's edge (clockwise from the top-left), with its outward normal. */
function edgeAt(s: number, w: number, h: number, r: number) {
  const sw = w - 2 * r, sh = h - 2 * r, arc = (Math.PI / 2) * r;
  const legs: [number, (t: number) => [number, number, number, number]][] = [
    [sw, (t) => [r + t, 0, 0, -1]],
    [arc, (t) => corner(w - r, r, -Math.PI / 2 + t / Math.max(r, 1e-6))],
    [sh, (t) => [w, r + t, 1, 0]],
    [arc, (t) => corner(w - r, h - r, t / Math.max(r, 1e-6))],
    [sw, (t) => [w - r - t, h, 0, 1]],
    [arc, (t) => corner(r, h - r, Math.PI / 2 + t / Math.max(r, 1e-6))],
    [sh, (t) => [0, h - r - t, -1, 0]],
    [arc, (t) => corner(r, r, Math.PI + t / Math.max(r, 1e-6))],
  ];
  for (const [len, at] of legs) {
    if (s <= len) return at(s);
    s -= len;
  }
  return legs[0][1](0);
  function corner(cx: number, cy: number, a: number): [number, number, number, number] {
    const nx = Math.cos(a), ny = Math.sin(a);
    return [cx + nx * r, cy + ny * r, nx, ny];
  }
}

/** True when a color reads as light, worked out by painting it, so any CSS color format works. */
let probe: CanvasRenderingContext2D | null = null;
function isLight(color: string) {
  probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!probe) return false;
  probe.clearRect(0, 0, 1, 1);
  probe.fillStyle = "#000";
  probe.fillStyle = color;
  probe.fillRect(0, 0, 1, 1);
  const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
  const lin = (c: number) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) > 0.4;
}

/* ---------- the button ---------- */

export const PeterTingleButton = forwardRef<HTMLButtonElement, PeterTingleButtonProps>(function PeterTingleButton(
  { color, textColor, senseColor, range = 120, lines, type = "button", className, style, children, ...rest },
  ref,
) {
  const btnRef = useRef<HTMLButtonElement>(null);
  const skinRef = useRef<HTMLSpanElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  useImperativeHandle(ref, () => btnRef.current!, []);

  // Props the running loop reads without restarting it.
  const opts = useRef({ range, lines, disabled: !!rest.disabled });
  opts.current = {
    range: Number.isFinite(range) ? Math.max(16, range) : 120,
    lines: Number.isFinite(lines) ? lines : undefined,
    disabled: !!rest.disabled,
  };

  // The label picks black or white on the fill, and checks again when the theme changes.
  useEffect(() => {
    const btn = btnRef.current, skin = skinRef.current;
    if (!btn || !skin) return;
    const apply = () =>
      btn.style.setProperty("--ipt-auto-text", isLight(getComputedStyle(skin).backgroundColor) ? "rgb(10 10 10)" : "rgb(255 255 255)");
    apply();
    const mo = new MutationObserver(apply);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
    const mq = matchMedia("(prefers-color-scheme: dark)");
    mq.addEventListener("change", apply);
    return () => {
      mo.disconnect();
      mq.removeEventListener("change", apply);
    };
  }, [color]);

  useEffect(() => {
    const btn = btnRef.current, canvas = canvasRef.current, skin = skinRef.current;
    if (!btn || !canvas || !skin) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduceMq = matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = reduceMq.matches;
    const onReduce = () => (reduced = reduceMq.matches);
    reduceMq.addEventListener("change", onReduce);

    const P = { x: 0, y: 0, has: false };
    let raf = 0, last = 0, level = 0, prevD = Infinity, speed = 0, wasInside = false;
    let burstAt = -1e9; // when the pointer last landed on the button
    let kick = { at: -1e9, amt: 0, dur: 1 }; // a tingle set off by a tap or focus
    let visible = true, cw = 0, ch = 0, dpr = 1;

    // The button's own size (offsetWidth ignores scaling), and where the pointer is in
    // the button's own pixels, so it also works inside a scaled-down preview.
    const shape = () => {
      const r = btn.getBoundingClientRect();
      const w = btn.offsetWidth, h = btn.offsetHeight;
      const sx = w ? r.width / w || 1 : 1, sy = h ? r.height / h || 1 : 1;
      const rad = parseFloat(getComputedStyle(btn).borderTopLeftRadius) || 0;
      return { w, h, rad: Math.min(rad, w / 2, h / 2), px: (P.x - r.left) / sx, py: (P.y - r.top) / sy };
    };

    const calm = () => {
      cancelAnimationFrame(raf);
      raf = 0;
      level = 0;
      speed = 0;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      btn.style.removeProperty("--ipt-t");
    };

    const frame = (now: number) => {
      raf = 0;
      const dt = Math.min(50, last ? now - last : 16);
      last = now;
      const { w, h, rad, px, py } = shape();
      if (opts.current.disabled || !w || !h) return calm();

      // Size the canvas to the button plus its overhang, sharp on retina (capped at 2x).
      const nd = Math.min(2, devicePixelRatio || 1);
      const nw = Math.ceil(w + PAD * 2), nh = Math.ceil(h + PAD * 2);
      if (nw !== cw || nh !== ch || nd !== dpr) {
        cw = nw; ch = nh; dpr = nd;
        canvas.width = cw * dpr;
        canvas.height = ch * dpr;
        canvas.style.width = `${cw}px`;
        canvas.style.height = `${ch}px`;
      }

      // 1. How strong the tingle should be: closer and faster means stronger.
      const R = opts.current.range;
      let target = 0, inside = false;
      if (P.has) {
        const d = distTo(px, py, w / 2, h / 2, w / 2, h / 2, rad);
        inside = d <= 0;
        const v = Number.isFinite(prevD) ? ((prevD - d) / dt) * 1000 : 0;
        speed += (Math.max(0, v) - speed) * 0.25;
        prevD = d;
        if (!inside && d < R) {
          const near = 1 - d / R;
          target = near ** 1.2 + RUSH * clamp(speed / RUSH_SPEED, 0, 1) * near;
        }
        if (inside && !wasInside && level > 0.12) burstAt = now; // landed on it: one flash out
      } else {
        prevD = Infinity;
        speed = 0;
      }
      wasInside = inside;
      const k = kick.amt * (1 - (now - kick.at) / kick.dur);
      target = clamp(Math.max(target, k), 0, 1);
      level += (target - level) * (1 - Math.exp(-dt / (target > level ? RISE_MS : inside ? RISE_MS : FALL_MS))); // calms fast once on it
      const bp = clamp((now - burstAt) / BURST_MS, 0, 1);
      const bursting = bp < 1;

      // 2. The sense lines.
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, cw, ch);
      ctx.strokeStyle = getComputedStyle(canvas).color;
      ctx.lineWidth = LINE_W;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      const perim = 2 * (w - 2 * rad) + 2 * (h - 2 * rad) + 2 * Math.PI * rad;
      const n = clamp(Math.round(opts.current.lines ?? perim / SPACING), 4, 64);
      const aim = P.has && !inside; // lines lean toward the pointer only while it is outside
      const ease = 1 - (1 - bp) ** 3;
      for (let i = 0; i < n; i++) {
        const [ex, ey, nx, ny] = edgeAt(((i + 0.5) / n) * perim, w, h, rad);
        const seed = (i * 0.618034) % 1;
        let face = 0.7;
        if (aim) {
          const dx = px - ex, dy = py - ey;
          face = Math.max(0, (nx * dx + ny * dy) / (Math.hypot(dx, dy) || 1));
        }
        const wgt = level * (BACK + (1 - BACK) * face);
        const ph = reduced ? 0.5 : (now / PULSE_MS + seed) % 1;
        // The steady pulse, and the one-off flash when the pointer lands.
        const strokes: [number, number, number][] = []; // [offset, length, alpha]
        if (wgt > 0.01) strokes.push([GAP + (reduced ? 2 : TRAVEL * ph), LEN_MIN + (LEN_MAX - LEN_MIN) * wgt, Math.min(1, wgt * 2) * (reduced ? 1 : Math.sqrt(Math.sin(Math.PI * ph)))]);
        if (bursting) strokes.push([GAP + (reduced ? 2 : BURST_PUSH * ease), LEN_MAX * (1 - 0.5 * bp), (1 - bp) ** 2 * 0.9]);
        for (const [off, len, alpha] of strokes) {
          if (alpha < 0.01) continue;
          const tx = -ny, ty = nx;
          const wig = reduced ? 0 : now * WIGGLE + seed * 6.283;
          ctx.globalAlpha = alpha;
          ctx.beginPath();
          for (let u = 0; u <= len; u += 1) {
            const a = AMP * Math.sin(Math.PI * (u / len)) * Math.sin((u / WAVE) * 6.283 - wig);
            const x = PAD + ex + nx * (off + u) + tx * a;
            const y = PAD + ey + ny * (off + u) + ty * a;
            if (u === 0) ctx.moveTo(x, y);
            else ctx.lineTo(x, y);
          }
          ctx.stroke();
        }
      }
      ctx.globalAlpha = 1;

      // 3. The shiver (and the flinch when the pointer lands).
      if (!reduced) {
        const a = level * level;
        const sx = SHIVER * a * (0.6 * Math.sin(now * 0.071) + 0.4 * Math.sin(now * 0.113 + 1.3));
        const sy = SHIVER * a * (0.6 * Math.sin(now * 0.083 + 2.1) + 0.4 * Math.sin(now * 0.127 + 0.4));
        const rot = TILT * a * Math.sin(now * 0.089 + 0.7);
        const sc = bursting ? 1 - FLINCH * Math.sin(Math.PI * bp) : 1;
        btn.style.setProperty("--ipt-t", `translate(${sx.toFixed(2)}px, ${sy.toFixed(2)}px) rotate(${rot.toFixed(2)}deg) scale(${sc.toFixed(3)})`);
      }

      // 4. Keep going while anything moves; stop when calm.
      if (level > 0.003 || target > 0 || bursting) raf = requestAnimationFrame(frame);
      else calm();
    };

    const wake = () => {
      if (!raf && visible && !opts.current.disabled) {
        last = 0;
        raf = requestAnimationFrame(frame);
      }
    };

    const onMove = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      P.x = e.clientX;
      P.y = e.clientY;
      P.has = true;
      if (raf || !visible || opts.current.disabled) return;
      const { w, h, rad, px, py } = shape();
      const d = distTo(px, py, w / 2, h / 2, w / 2, h / 2, rad);
      if (d < opts.current.range && d > 0) {
        prevD = Infinity;
        wasInside = false;
        wake();
      } else wasInside = d <= 0;
    };
    const onOut = (e: PointerEvent) => {
      if (!e.relatedTarget) {
        P.has = false;
        if (raf) wake();
      }
    };
    const onLeave = () => {
      P.has = false;
    };
    const tingle = (amt: number, dur: number) => {
      kick = { at: performance.now(), amt, dur };
      wake();
    };
    const onDown = (e: PointerEvent) => {
      if (e.pointerType === "touch") {
        P.has = false;
        tingle(1, TAP_MS);
      }
    };
    const onFocus = () => {
      if (btn.matches(":focus-visible")) tingle(FOCUS_AMT, FOCUS_MS);
    };
    const onVis = () => document.hidden && calm();

    const io = new IntersectionObserver(([en]) => {
      visible = en.isIntersecting;
      if (!visible) calm();
    }, { rootMargin: `${PAD + 120}px` });
    io.observe(btn);
    addEventListener("pointermove", onMove, { passive: true });
    document.addEventListener("pointerout", onOut, { passive: true });
    addEventListener("blur", onLeave);
    btn.addEventListener("pointerdown", onDown, { passive: true });
    btn.addEventListener("focus", onFocus);
    document.addEventListener("visibilitychange", onVis);
    return () => {
      io.disconnect();
      reduceMq.removeEventListener("change", onReduce);
      removeEventListener("pointermove", onMove);
      document.removeEventListener("pointerout", onOut);
      removeEventListener("blur", onLeave);
      btn.removeEventListener("pointerdown", onDown);
      btn.removeEventListener("focus", onFocus);
      document.removeEventListener("visibilitychange", onVis);
      calm();
    };
  }, []);

  return (
    <>
      <style>{CSS}</style>
      <button
        ref={btnRef}
        type={type}
        className={className ? `ipt ${className}` : "ipt"}
        style={
          {
            ...(color ? { "--ipt-ink": color } : null),
            ...(textColor ? { "--ipt-text": textColor } : null),
            ...(senseColor ? { "--ipt-sense": senseColor } : null),
            ...style,
          } as CSSProperties
        }
        {...rest}
      >
        <canvas ref={canvasRef} className="ipt-sense" width={0} height={0} aria-hidden="true" />
        <span ref={skinRef} className="ipt-skin" aria-hidden="true" />
        <span className="ipt-label">{children}</span>
      </button>
    </>
  );
});
```

Usage

Use it like any button. Everything has a default.

"use client";
import { PeterTingleButton } from "@/components/ui/peter-tingle-button";

export function HeroAction() {
  return (
    <PeterTingleButton
      color="var(--foreground)"       // optional: the fill
      textColor="var(--background)"   // optional: the label
      senseColor="var(--primary)"     // optional: the sense lines
      onClick={() => router.push("/signup")}
    >
      Get started
    </PeterTingleButton>
  );
}

The sense lines reach about 40px past the edge. Give it that much room, or a box with hidden overflow will cut them off.

How it senses

The tingle starts when your pointer comes within range (120px) of the edge and grows stronger all the way in. Coming in fast makes it stronger still, so a rush feels like a threat and a slow approach feels like a hunch.

The lines are longest and brightest on the side your pointer comes from, so the button seems to look at it. The moment the pointer lands on the button, the lines flash outward once and everything goes still, so your click is always steady. Phones have no pointer to sense, so a tap sets off one short tingle all around. Keyboard focus sets off a softer one. With reduced motion, the lines simply fade in and out, with no shiver and no waves.

Props

PropDefaultWhat it does
children—The label. Keep it short: it stays on one line and trims with … if it runs out of room.
colorthe text color around itThe button's fill. Any CSS color, including var(--…).
textColorblack or whiteThe label color. By default it picks whichever reads best on the fill.
senseColorthe fillThe color of the wavy sense lines. A brand color works well here.
range120Distance in px from the button's edge where the tingle starts. It grows stronger all the way to the edge.
linesone per 24px of edgeHow many sense lines go around the edge, 4 to 64. A 160px wide button gets about 15.
onClick, type, disabled, …type="button"Everything a normal button takes. Disabled turns the tingle off.
className / style—On the button itself. It is 44px tall with 22px sides; change height and padding here.