Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 (or viewport.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. from bind: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).
  • info holds point, delta, offset (from the start) and velocity, 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. from bind: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) to 1 (freely), default 0.35. Also { top, left, right, bottom } per side, or false.
  • dragMomentum: fling with inertia on release (default true).
  • dragTransition: the inertia options — power, timeConstant, bounceStiffness, bounceDamping, min, max, modifyTarget to 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 through dragControls.
  • onMeasureDragConstraints(constraints): adjust the measured constraints.
  • Events: onDragStart(event, info), onDrag(event, info), onDragEnd(event, info) and onDragTransitionEnd().
<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>

Was this page helpful?