physics — motion that obeys the hand
Stage: Phase 9 — Motion (DIRECTION-gated) - Reads: design/DIRECTION.md, design/SYSTEM.md §motion - Writes: 1–2 interactive moments (Magnetic wrapper, drag surfaces, cursor follower)
Standard
Anything that tracks a gesture uses a spring, not a duration — the hand is analog and the response must be too. But physics is a scarce resource: 1–2 moments per site, pointer-fine devices only, and only when DIRECTION.md's motion stance says playful/tactile/interactive. A law firm gets zero magnetic buttons. The empirical test: the effect runs 60fps in a DevTools performance recording, does nothing on touch devices, and disappears cleanly under reduced motion.
- Springs by stiffness/damping, never by duration. One recipe table (below), values kept in one constants file so the site's "feel" tunes in one place.
- Displacement is small. Magnetic pull ≤ 12px on buttons; a springy press is scale 0.97, not 0.9.
- Overshoot belongs to gestures. Section entrances use the easing curves from
motion-language; bounce on a scroll reveal is slop. - Touch degrades to nothing. Magnetic hover and cursor followers are
(hover: hover) and (pointer: fine)only. Drag must not fight page scroll. - Bundle honesty (STACK.md): hover/tap/spring animations run under
domAnimation;dragandlayoutrequiredomMax(+25kb, 12.x-measured — unverified for 13 per STACK.md). Adding one draggable element costs the whole delta — budget it consciously. - Gestures never leave motion. Even on a site whose DIRECTION.md commissioned anime.js for an SVG moment, drag and pointer tracking stay here: anime's
createDraggableis its heaviest module (per STACK.md) and a second drag system is fragmentation, not capability. The same line holds against a renderer: on a DIRECTION-commissionedultraweb:set-designbuild, pointer→scene raycasting and camera damping are scene input and stay inside the canvas, while every DOM spring, magnetic hover and cursor follower stays here on motion —domAnimationuntil a realdrag/layoutmoment earnsdomMax, per the bundle-honesty rule above. If the thing that moves is a DOM node it is this skill; if it is anObject3Dit is not.OrbitControls/CameraControlsare never installed on a site-scale scene — a free camera fights an authored journey, and a camera the user can lose is not a site.
Process
- Gate check. Does DIRECTION.md name tactility, playfulness, or interactivity in its motion stance? No → stop; return to the pipeline.
micro-interactionscovers everything below the physics threshold. - Budget. Pick 1–2 moments, usually attached to the signature move: the primary CTA, a work-gallery drag, a hero cursor effect. Never all three.
- Pick spring values from the recipe table; export them from
lib/motion.ts(or the site's constants module) beside the easing tokens. - Guard for input type: render the plain element on touch/coarse pointers; attach physics only under
(hover: hover) and (pointer: fine). - If drag is used, switch the app's
LazyMotionfeatures todomMax— understrict, drag withdomAnimationfails. - Tune on real hardware by hand — throw the drag, circle the magnet. Spring feel cannot be judged from code.
- Verify: DevTools performance recording ≥5s of interaction at 60fps, touch emulation shows no dead effects, reduced-motion emulation snaps springs to end state or disables the moment.
Spring recipes (starting values, tune by hand)
| Feel | stiffness | damping | mass | Use for |
|---|---|---|---|---|
| Snap (no overshoot) | 400–500 | 30–35 | 1 | toggles, press response, UI that must feel precise |
| Playful (slight overshoot) | 250–350 | 18–24 | 1 | magnetic hover, springy icons, badge pops |
| Trailing (loose) | 150–250 | 15–20 | 1 | cursor followers, elements that lag the pointer |
| Heavy (luxurious) | 100–170 | 20–26 | 1–1.5 | large media panels, drawer settle |
Damping < 10 wobbles forever and reads as broken — never ship it.
Moves
Magnetic hover — element leans toward the pointer, springs home on leave:
"use client";
import { m, useSpring } from "motion/react";
const spring = { stiffness: 300, damping: 22 };
export function Magnetic({ children }: { children: React.ReactNode }) {
const x = useSpring(0, spring);
const y = useSpring(0, spring);
return (
<m.div
style={{ x, y }}
=> {
const r = e.currentTarget.getBoundingClientRect();
x.set((e.clientX - r.left - r.width / 2) * 0.25);
y.set((e.clientY - r.top - r.height / 2) * 0.25);
}}
=> { x.set(0); y.set(0); }}
>
{children}
</m.div>
);
}
Pull factor 0.2–0.3; cap the result so buttons never travel more than ~12px. Refinement: counter-translate the label at half the container's offset so text stays calmer than its frame.
Drag with constraints — galleries, before/after sliders, playful cards:
<m.div drag="x" dragConstraints={trackRef} dragElastic={0.15} whileTap={{ cursor: "grabbing" }} />
Always constrain (dragConstraints as ref or { left, right } bounds); dragElastic 0.1–0.2 gives edges a physical tug. Horizontal drag inside a vertically scrolling page is fine; free-axis drag inside scroll is not. Requires domMax.
Cursor follower — a dot/label trailing the pointer with the Trailing recipe (useSpring per axis fed from pointer events). Hide the native cursor only inside the zone that replaces it (cursor-none scoped to that container), never site-wide. Pointer-fine only; the label must duplicate information available elsewhere (it's ornament, not UI). (award-canon: The Cursor as Narrator — the cursor may label an affordance ("explore"/"watch") or point direction, but only as pure enhancement over the native cursor and real focus/hover states, and never on touch.)
Spring press — whileTap={{ scale: 0.97 }} with transition={{ type: "spring", stiffness: 500, damping: 30 }} on the 1–2 elements that deserve more than the CSS active state from micro-interactions.
Prove-It gesture (award-canon: The Prove-It Gesture) — one deliberate press-and-hold or drag-to-intensify that reveals a hidden layer or teaches the model by consequence: onPointerDown/onPointerUp cross-fading an overlay, or a held pointer ramping a spring useMotionValue that eases home on release. ONE per site, tied to the signature move — never the only path to key info: pair a visible affordance and a click/tap fallback, keep it keyboard-operable, and snap to the end state under reduced motion.
Anti-patterns
- Physics on a brief whose DIRECTION.md motion stance is calm/editorial — the gate exists for a reason.
dragwithoutdragConstraints— grep for it; unbounded flingable UI.cursor: none/cursor-noneonbodyorhtml— grep; scope it to the follower's zone.- Missing pointer guards — magnetic/follower code with no
(pointer: fine)check ships dead weight and ghost behavior to phones. - Spring scale/pull on every card in a grid — physics on list items is mis-hover chaos; one moment, not a treatment.
damping: 5-class values — perpetual wobble.- Loading
domMaxwhen nothing drags — the whole domMax delta for hover springsdomAnimationalready covers (STACK.md). - Springs on section entrances — that's
scroll-motion's territory and it uses easing curves, not bounce.
Worked example — Framewalk, cursor-reactive hero fog for a Steam launch
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.