Gestures
Hover, tap, focus, in-view, pan and drag gestures, with Motion's gesture props.
Overview
Mercury supports Motion’s gestures through the same props. Each while* prop is a target (or variant label) that is active while the gesture is: Motion animates to it, and back to whatever is underneath when the gesture ends. Gestures stack by priority — animate < whileInView < whileFocus < whileHover < whileTap < whileDrag < exit — so a press on a hovered button animates from the hover state and returns to it on release.
Each target can have its own transition; otherwise the attachment’s transition is used.
Hover
whileHover: target while the pointer is over the element.onHoverStart(event, info)/onHoverEnd(event, info).
Hover ignores touch input, so a tap on a phone doesn’t leave the element stuck in its hover state.
<div
{@attach mercury({
whileHover: { scale: 1.1, transition: { type: 'spring', stiffness: 150, damping: 20 } }
})}
/>
<script lang="ts">
import { mercury } from '@omicrxn/mercury';
</script>
<div class="flex min-h-80 flex-col justify-center gap-8 p-8">
<div class="mx-auto flex w-full max-w-2xl flex-col gap-6">
<div class="flex flex-col gap-1">
<h2 class="text-xl font-bold">Hover</h2>
<p class="text-sm text-muted-foreground">
Hover over the box, then press it to feel both responses.
</p>
</div>
<div
class="flex min-h-48 w-full items-center justify-center rounded-md border border-demo-stroke border-dashed bg-demo-surface p-8"
>
<button
type="button"
aria-label="Hover feedback example"
class="box size-16 cursor-pointer rounded-md border border-demo-stroke bg-demo-fill focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-demo-accent"
{@attach mercury({
animate: { scale: 1 },
whileHover: {
scale: 1.02,
transition: { duration: 0.15, ease: [0.25, 0.46, 0.45, 0.94] }
},
whileTap: { scale: 0.97, transition: { duration: 0.15, ease: [0.25, 0.46, 0.45, 0.94] } }
})}
></button>
</div>
</div>
</div>
Tap
whileTap: target while the element is pressed.onTapStart(event, info)— the press began.onTap(event, info)— the press ended on the element.onTapCancel(event, info)— the pointer was released outside it.globalTapTarget: end the press wherever the pointer is released.propagate: { tap: false }: stop a press on this element from also pressing its ancestors.
Presses work from the keyboard too: Enter presses a focused element. Like Motion, Mercury makes a tappable element focusable (tabindex="0") if it isn’t already. A disabled <button> doesn’t respond.
<button {@attach mercury({ whileTap: { scale: 0.95 }, onTap: () => console.log('tapped') })}>
Press me
</button>
<script lang="ts">
import { mercury } from '@omicrxn/mercury';
</script>
<div class="flex min-h-80 flex-col justify-center gap-8 p-8">
<div class="mx-auto flex w-full max-w-2xl flex-col gap-6">
<div class="flex flex-col gap-1">
<h2 class="text-xl font-bold">Tap</h2>
<p class="text-sm text-muted-foreground">
Click, tap, or press Space on the box for press feedback.
</p>
</div>
<div
class="flex min-h-48 w-full items-center justify-center rounded-md border border-demo-stroke border-dashed bg-demo-surface p-8"
>
<button
type="button"
aria-label="Tap feedback example"
class="box size-16 cursor-pointer rounded-md border border-demo-stroke bg-demo-fill focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-demo-accent"
{@attach mercury({
animate: { scale: 1 },
whileHover: {
scale: 1.02,
transition: { duration: 0.15, ease: [0.25, 0.46, 0.45, 0.94] }
},
whileTap: { scale: 0.97, transition: { duration: 0.15, ease: [0.25, 0.46, 0.45, 0.94] } }
})}
></button>
</div>
</div>
</div>
Focus
whileFocus is active while the element has focus that the browser would show (:focus-visible) — keyboard focus, not a mouse click on a button.
<button {@attach mercury({ whileFocus: { scale: 1.08 } })}>Focus me</button>
<script lang="ts">
import { mercury } from '@omicrxn/mercury';
</script>
<div class="flex flex-col items-center gap-4">
<p class="text-sm text-muted-foreground">Press Tab to move focus between the buttons.</p>
<div class="flex gap-3">
{#each ['One', 'Two', 'Three'] as label (label)}
<button
type="button"
class="min-h-11 rounded-md border border-demo-stroke bg-demo-fill px-4 text-sm font-medium text-demo-fill-foreground outline-none"
{@attach mercury({
whileFocus: { scale: 1.08, boxShadow: '0 0 0 3px var(--demo-accent)' },
whileTap: { scale: 0.95 },
transition: { type: 'spring', duration: 0.25, bounce: 0.3 }
})}
>
{label}
</button>
{/each}
</div>
</div>
In view
whileInView: target while the element is in the viewport (orviewport.root).onViewportEnter(entry)/onViewportLeave(entry).viewport:once: stay in the in-view state after the first time.root: the scrollable ancestor to observe, as an element (e.g. frombind:this) or{ current }.margin: grow or shrink the detection area, like CSS margin ('0px 0px -100px 0px').amount: how much of the element must be visible:'some'(default),'all', or a 0–1 fraction.
<div
{@attach mercury({
initial: { opacity: 0, y: 24 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, amount: 0.5 }
})}
/>
<script lang="ts">
import { mercury } from '@omicrxn/mercury';
let scrollRoot = $state<HTMLElement>();
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scroll region needs keyboard focus to scroll.) -->
<div
bind:this={scrollRoot}
role="region"
aria-label="Scroll animation example"
tabindex="0"
class="flex h-64 w-full flex-col items-center overflow-y-auto overscroll-contain rounded-md border border-border p-4 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-demo-accent"
>
<div class="flex h-72 w-full shrink-0 flex-col items-center gap-2 pt-8 text-sm font-medium">
<p>Scroll down to move and rotate the box</p>
<span aria-hidden="true">↓</span>
</div>
{#if scrollRoot}
<div
class="box shrink-0"
{@attach mercury({
whileInView: { x: 64, rotate: 180 },
viewport: { root: scrollRoot, amount: 0.5 },
transition: { duration: 0.6, ease: 'easeInOut' }
})}
></div>
{/if}
<div class="h-24 w-full shrink-0" aria-hidden="true"></div>
</div>
For animations linked to scroll position, see Motion values.
Pan
Pan reports a pointer moving across the element, without moving it:
onPanStart(event, info),onPan(event, info),onPanEnd(event, info),onPanSessionStart(event, info).infoholdspoint,delta,offset(from the start) andvelocity, in pixels.
Pan starts after the pointer moves 3px. For touch input, add touch-action: none (or pan-y…) to the element so the browser doesn’t scroll instead.
Drag
Set drag: true to make an element draggable, or drag: 'x' / 'y' to lock it to one axis. Mercury runs Motion’s drag gesture: the element follows the pointer through its x / y motion values, so it composes with every other animation, and flings with inertia when released.
whileDrag: target while dragging.dragConstraints: an element (e.g. frombind:this) or{ current }ref the element must stay inside, or pixel offsets from its position:{ left: -100, right: 100, top: 0, bottom: 0 }.dragElastic: how far it can be pulled past its constraints:0(none) to1(freely), default0.35. Also{ top, left, right, bottom }per side, orfalse.dragMomentum: fling with inertia on release (defaulttrue).dragTransition: the inertia options —power,timeConstant,bounceStiffness,bounceDamping,min,max,modifyTargetto snap to a grid…dragSnapToOrigin: animate back to the start on release (true,'x'or'y').dragDirectionLock: lock to the axis the pointer first moves along.onDirectionLock(axis)reports it.dragPropagation: let a drag on this element also drag draggable ancestors.dragListener: false: only start drags throughdragControls.onMeasureDragConstraints(constraints): adjust the measured constraints.- Events:
onDragStart(event, info),onDrag(event, info),onDragEnd(event, info)andonDragTransitionEnd().
<script lang="ts">
import { mercury } from '@omicrxn/mercury';
let container = $state<HTMLElement>();
</script>
<div bind:this={container} class="boundary">
<div
{@attach mercury({
drag: true,
dragConstraints: container,
dragElastic: 0.2,
whileDrag: { scale: 1.1 }
})}
class="box"
/>
</div>
<script lang="ts">
import { mercury } from '@omicrxn/mercury';
let container = $state<HTMLElement>();
</script>
<div class="flex w-full max-w-[400px] flex-col items-center gap-4">
<h2 class="text-xl font-bold">Drag</h2>
<p class="text-sm text-muted-foreground">Drag the box, or focus it and use the arrow keys.</p>
<div
bind:this={container}
class="flex h-64 w-full items-center justify-center overflow-hidden rounded-md border-2 border-demo-accent"
>
{#if container}
<button
type="button"
aria-label="Drag the box, or move it with the arrow keys"
{@attach mercury({
drag: true,
dragConstraints: container,
dragElastic: 0.12
})}
class="box draggable touch-none select-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-demo-accent"
></button>
{/if}
</div>
</div>
Like Motion, Mercury sets touch-action (and disables text selection) on draggable elements, so touch drags don’t scroll the page. Dragging works with constraints that move — they’re measured when each drag starts and when the window or constraint element resizes.
Mercury also lets a focused draggable element move with the arrow keys (Shift for bigger steps), within its constraints. Set dragKeyboard: false to turn it off, or a number to change the 10px step.
Drag controls
To start a drag from somewhere else — a handle, or a pointerdown anywhere on the page — create drag controls and pass them to the draggable element. This is Motion’s useDragControls():
<script lang="ts">
import { mercury, createDragControls } from '@omicrxn/mercury';
const controls = createDragControls();
</script>
<div onpointerdown={(event) => controls.start(event)}>Handle</div>
<div {@attach mercury({ drag: true, dragControls: controls, dragListener: false })}>Card</div>
controls.start(event, { snapToCursor: true }) centers the element on the pointer first. controls.stop() and controls.cancel() end the drag.
<script lang="ts">
import { mercury, createDragControls } from '@omicrxn/mercury';
let area = $state<HTMLElement>();
const controls = createDragControls();
</script>
<div class="flex w-full max-w-[400px] flex-col items-center gap-4">
<p class="text-sm text-muted-foreground">
Drag the card by its handle. It stays inside the area, and the ball snaps back.
</p>
<div
bind:this={area}
class="relative flex h-56 w-full items-center justify-around rounded-md border-2 border-dashed border-demo-stroke"
>
<div
class="w-28 rounded-md border border-demo-stroke bg-demo-surface shadow-sm"
{@attach mercury({
drag: true,
dragControls: controls,
dragListener: false,
dragConstraints: area,
dragElastic: 0.1,
whileDrag: { scale: 1.05, boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }
})}
>
<div
role="button"
tabindex="-1"
aria-label="Drag handle"
class="flex h-6 cursor-grab touch-none items-center justify-center rounded-t-md bg-demo-container active:cursor-grabbing"
onpointerdown={(event) => controls.start(event)}
>
<span class="h-1 w-8 rounded-full bg-demo-stroke"></span>
</div>
<p class="p-3 text-sm">Card</p>
</div>
<button
type="button"
aria-label="Drag the ball, or move it with the arrow keys"
class="circle cursor-grab active:cursor-grabbing"
{@attach mercury({
drag: true,
dragSnapToOrigin: true,
whileDrag: { scale: 1.2 },
transition: { type: 'spring', bounce: 0.4 }
})}
></button>
</div>
</div>