import { useRef } from "react";
import {
motion,
useMotionValue,
useTransform,
useSpring,
useTime,
useReducedMotion,
type MotionValue,
} from "framer-motion";
// Ambient breathing dot grid with a cursor force field.
//
// Dots pulse outward from the center in a continuous radial wave. The
// cursor only affects POSITION: moving over the grid REPELS the dots,
// holding the button ATTRACTS them (a gravity well), and each dot
// springs back to its home.
//
// Breathing is independent of the cursor — there's no global hover
// pause. A dot only "calms" (its pulse eases down) in proportion to
// how far it's actually displaced: calm = displacement / MAX_DISP.
// Because calm rides the displacement spring, it eases smoothly and
// per-dot, so nothing flashes when the cursor enters or leaves.
//
// Tuning: FALLOFF (reach), MAX_DISP (throw distance), SPRING (feel),
// PERIOD/SCALE_AMP/OPACITY_AMP (the breath). Cursor state lives in
// motion values, so moving the mouse never triggers a React re-render.
type DotProps = {
homeX: number;
homeY: number;
phase: number;
reduced: boolean;
time: MotionValue<number>;
cursorX: MotionValue<number>;
cursorY: MotionValue<number>;
active: MotionValue<number>;
attract: MotionValue<number>;
};
function Dot({
homeX,
homeY,
phase,
reduced,
time,
cursorX,
cursorY,
active,
attract,
}: DotProps) {
// Force field → displacement. Read every shared value up front so the
// transform subscribes to all of them, no matter which branch returns.
const dispX = useTransform(() => {
const a = active.get();
const cx = cursorX.get();
const cy = cursorY.get();
const repel = attract.get() === 0;
if (a === 0) return 0;
return offset(homeX, homeY, cx, cy, repel)[0];
});
const dispY = useTransform(() => {
const a = active.get();
const cx = cursorX.get();
const cy = cursorY.get();
const repel = attract.get() === 0;
if (a === 0) return 0;
return offset(homeX, homeY, cx, cy, repel)[1];
});
const x = useSpring(dispX, SPRING);
const y = useSpring(dispY, SPRING);
// Calm tracks the field intensity at the dot's home: 1 right under the
// cursor, easing to 0 at the falloff radius (and 0 when the cursor is
// away). Its own damped spring eases it in/out — smooth and per-dot,
// with no global hover state, so nothing flashes on enter/leave.
const intensity = useTransform(() => {
const a = active.get();
const cx = cursorX.get();
const cy = cursorY.get();
if (a === 0) return 0;
const dist = Math.hypot(homeX - cx, homeY - cy);
if (dist > FALLOFF) return 0;
const t = dist / FALLOFF;
return (1 - t) * (1 - t);
});
const calm = useSpring(intensity, CALM_SPRING);
// Continuous breathing pulse (raised cosine, 0..1) with a radial phase
// offset. Its amplitude is attenuated by `calm`. x/y (translate),
// scale, and opacity are independent style channels, so displacement
// and breathing compose without fighting.
const pulse = useTransform(() => {
if (reduced) return 0;
const tSec = time.get() / 1000;
return (1 - Math.cos((2 * Math.PI) / PERIOD * (tSec - phase))) / 2;
});
const scale = useTransform(
() => 1 + SCALE_AMP * pulse.get() * (1 - calm.get())
);
const opacity = useTransform(
() => BASE_OPACITY + OPACITY_AMP * pulse.get() * (1 - calm.get())
);
return (
<motion.div
style={{
x,
y,
scale,
opacity,
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: "50%",
background: "currentColor",
pointerEvents: "none", // the wrapper handles all pointer events
}}
/>
);
}
export default function BreathingDots() {
const gridRef = useRef<HTMLDivElement>(null);
const time = useTime();
const cursorX = useMotionValue(0); // grid-local px
const cursorY = useMotionValue(0);
const active = useMotionValue(0); // 0 = cursor outside, 1 = inside (gates the force)
const attract = useMotionValue(0); // 0 = repel (default), 1 = attract (button held)
const reduced = useReducedMotion() ?? false;
function handleMove(e: React.PointerEvent) {
if (reduced) return;
const grid = gridRef.current;
if (!grid) return;
const r = grid.getBoundingClientRect();
cursorX.set(e.clientX - r.left);
cursorY.set(e.clientY - r.top);
}
function engage() {
if (reduced) return;
active.set(1);
}
function release() {
active.set(0);
attract.set(0);
}
function press() {
if (reduced) return;
attract.set(1);
}
function lift() {
attract.set(0);
}
const dots = [];
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
const distance = Math.sqrt(
Math.pow(row - GRID_SIZE / 2, 2) + Math.pow(col - GRID_SIZE / 2, 2)
);
dots.push(
<Dot
key={`${row}-${col}`}
homeX={col * PITCH + DOT_SIZE / 2}
homeY={row * PITCH + DOT_SIZE / 2}
phase={distance * WAVE}
reduced={reduced}
time={time}
cursorX={cursorX}
cursorY={cursorY}
active={active}
attract={attract}
/>
);
}
}
return (
// Padded wrapper = invisible hit area so the field engages before the
// cursor reaches the tight grid box. Coordinates are measured off the
// inner grid, so the padding doesn't affect the math.
<div
onPointerMove={handleMove}
onPointerEnter={engage}
onPointerLeave={release}
onPointerDown={press}
onPointerUp={lift}
onPointerCancel={release}
onDragStart={(e) => e.preventDefault()}
style={{
padding: PAD,
display: "inline-block",
touchAction: "none",
userSelect: "none",
WebkitTapHighlightColor: "transparent",
}}
>
<div
ref={gridRef}
style={{
display: "grid",
gridTemplateColumns: `repeat(${GRID_SIZE}, ${DOT_SIZE}px)`,
gridTemplateRows: `repeat(${GRID_SIZE}, ${DOT_SIZE}px)`,
gap: GAP,
color: "var(--foreground)",
}}
>
{dots}
</div>
</div>
);
}
// Displacement of a dot from its home, given the cursor position.
// `repel` true pushes the dot away from the cursor; false pulls it in.
function offset(
homeX: number,
homeY: number,
cursorX: number,
cursorY: number,
repel: boolean
): [number, number] {
const dx = homeX - cursorX; // vector pointing from cursor → dot
const dy = homeY - cursorY;
const dist = Math.hypot(dx, dy) || 0.0001;
if (dist > FALLOFF) return [0, 0];
const t = dist / FALLOFF;
const falloff = (1 - t) * (1 - t); // strong core, soft edge
const amount = Math.min(MAX_DISP, MAX_DISP * falloff) * (repel ? 1 : -1);
return [(dx / dist) * amount, (dy / dist) * amount];
}
// Layout
const GRID_SIZE = 8;
const DOT_SIZE = 6;
const GAP = 28;
const PITCH = DOT_SIZE + GAP; // distance between dot centers
// Breathing pulse
const PERIOD = 3; // seconds per breath
const WAVE = 0.15; // phase offset per unit of grid distance (radial wave)
const SCALE_AMP = 0.8; // scale ranges 1 .. 1 + SCALE_AMP
const BASE_OPACITY = 0.25;
const OPACITY_AMP = 0.35; // opacity ranges BASE .. BASE + AMP
// Cursor force field
const FALLOFF = 120; // px radius of influence
const MAX_DISP = 26; // px max displacement (~0.75 × pitch — crowd, don't overlap)
const PAD = 100; // invisible hit-area padding around the grid
const SPRING = { stiffness: 220, damping: 18, mass: 0.9 }; // displacement — underdamped → elastic
const CALM_SPRING = { stiffness: 150, damping: 30 }; // calm — overdamped → ease, no overshoot