Skills
Skill 11 of 18
Build drag, swipe, and sheet interactions that track the finger 1:1 and stay interruptible.
5 minutes · 1,074 words · 14 sections
Install
npx skills add emilkowalski/skills --skill gesturesnpx skills add emilkowalski/skillsThe first command installs just this skill, by the name in its SKILL.md; the second installs the whole repository.
How to build gestures that feel physical. A gesture is direct manipulation: while the finger is down, the UI is the finger; the moment it lifts, physics takes over. Every rule below follows from that split.
Doing a drawer gesture right means momentum-based dragging, overlay opacity matched to drag progress, velocity-aware dismissal, focus trapping, and Escape-to-close. That is weeks of edge cases, not an afternoon. For a bottom sheet / drawer, default to Vaul (opens in a new tab) — it exists precisely because this is hard to hand-roll. Build the gesture yourself only when no library covers the interaction (custom swipe cards, bespoke drag interactions). Use the pick-ui-library skill when unsure.
Walk this split for any gesture you build. Mixing the phases up is the #1 cause of gestures that feel wrong.
Is the pointer currently down?
├── Yes → TRACK phase: position maps 1:1 to the pointer.
│ No easing. No duration. No spring. The element IS the finger.
└── No → RELEASE phase: physics finishes the motion.
A spring seeded with the release velocity. Never a keyframe.useMotionValue (or direct transform writes), never a spring or a transition — a spring during tracking makes the element trail the finger and feel disconnected, like dragging something through syrup.transform directly on the dragged element. Never update a CSS variable on a parent to move children: CSS variables are inheritable, so every change recalculates styles for all descendants. This exact bug made Vaul’s drag laggy past ~20 list items; setting transform on the element fixed it.dragElastic ≈ 0.2), not stop dead — hard stops feel like hitting a wall; resistance communicates “there’s nothing further” physically.onDragEnd‘s info.offset and info.velocity. Distance-only thresholds make flicks feel ignored.The user must be able to catch a sheet mid-close and drag it back. Springs allow this: when retargeted mid-flight they keep their current velocity, so the redirect is seamless. CSS @keyframes restart from zero and cannot be caught — this is why Sonner’s early CSS enter animation made toasts jump when a second toast arrived. Never use a keyframe animation on anything a gesture can touch. CSS transitions and springs only.
The dragged element is rarely alone — an overlay fades, the page behind scales. Derive these from drag progress, don’t animate them separately:
const y = useMotionValue(0);
const overlayOpacity = useTransform(y, [0, drawerHeight], [1, 0]);One source of truth means the overlay is always exactly as faded as the drawer is dismissed — including mid-gesture, including when the user reverses. Two separate animations drift apart the moment a gesture is interrupted.
<motion.div
drag="y"
dragConstraints={{ top: 0 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
if (info.offset.y > threshold || info.velocity.y > 500) dismiss();
}}
/>dragMomentum is on by default — right for a flung card, wrong for a simple reposition; disable it when the element should stop where the finger stops. dragConstraints takes a ref or bounds object.
Vaul mimics the iOS sheet with pure CSS to stay small: cubic-bezier(0.32, 0.72, 0, 1) over 500ms. The curve is extremely steep at the start — that’s what lets a 500ms duration still feel snappy while landing with a spring-like gentle settle. Two rules from it:
[vaul-drawer] { transition: transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); }
[vaul-overlay] { transition: opacity 0.2s cubic-bezier(0.165, 0.84, 0.44, 1); }Mark interactive children with data-vaul-no-drag so pressing a button inside the drawer doesn’t start a drag.
An element dismisses in the direction it entered. Sonner’s toast slides up from the bottom, so swipe-down dismisses it — enter and exit share one axis, which is what makes the gesture feel discoverable and intuitive without instruction. A gesture that dismisses along an axis the element never moved on has to be learned; one that reverses the entrance is felt.
Hold-to-delete and similar press-and-hold interactions use linear during the hold — the animation visualizes time passing, and time passes linearly; any other curve lies about how long is left. On release before completion, snap back fast with ease-out. The deliberate phase is slow and honest; the cancel is instant. Symmetric timing here is a bug.
Run through this for every gesture you ship:
::before hitbox instead of growing the layout.touch-action on the drag axis (touch-action: pan-x for a vertical drag, etc.) so the browser doesn’t fight the gesture with native scrolling.@media (hover: hover) and (pointer: fine) — touch fires phantom hovers on tap.Tracking is direct manipulation — the user is producing the motion — so it stays. What reduces: momentum flings, bounce, and any decorative motion around the gesture. On release under prefers-reduced-motion, settle with a short opacity/position change instead of a bouncy spring. See the animation-accessibility skill for the full two-variant workflow.
A “weird” spring is usually fixed by increasing damping. A gesture that stutters is a performance problem — check the track-phase rules above first, then use the animation-performance skill. To diagnose a feeling you can’t name, use the debug-animation skill: record the gesture and scrub it frame by frame.
Build drag, swipe, and sheet interactions that track the finger 1:1 and stay interruptible. Use when building or fixing anything the user drags, swipes, flings, holds, or dismisses with a gesture — drawers, sheets, swipe-to-dismiss toasts and cards, drag-to-reorder, pull-to-refresh, hold-to-confirm — or when a gesture feels laggy, disconnected from the finger, jumps on release, or can't be caught mid-animation. Triggers on — gesture, drag, draggable, swipe, swipe to dismiss, fling, momentum, velocity, drawer, sheet, bottom sheet, snap points, Vaul, dragConstraints, dragElastic, dragMomentum, onDragEnd, whileDrag, useMotionValue, rubber-band, overscroll, pull to refresh, hold to delete, press and hold, touch-action, dismiss threshold, "doesn't follow my finger", "jumps when I let go", "can't grab it mid-animation".
The verbatim description from this skill’s front matter — the string an agent matches on to decide whether to load it.
main, last pushed 8 November 2026.SKILL.md, not by matching a directory convention. One layout observed: skills/*/SKILL.md.h1 and no skipped levels:/emilkowalski/skills.md, and each skill at its own .md URL.