Gestures
Hover, press, focus, pan and drag gestures that work across mouse, touch and keyboard, with animation props for each.
motion components extend Svelte's event attributes with a set of UI gesture recognisers: hover, press, focus, pan and drag. They also detect when an element enters the viewport, which is covered in scroll animations.
Each gesture has callbacks, and most have a while prop that animates the element while the gesture is active.
Animation props
motion components have five gesture animation props: whileHover, whilePress, whileFocus, whileDrag and whileInView. Each sets a target to animate to while its gesture is active. When the gesture ends, the element animates back.
<motion.button
whileHover={{
scale: 1.2,
transition: { duration: 1 },
}}
whilePress={{ scale: 0.9 }}
/> Each prop accepts a target, or the name of a variant defined in variants. Variants flow down to children as usual, so a gesture on a parent can animate its children too:
<script lang="ts">
import { motion, type Variants } from "motion-sv";
const button: Variants = {
rest: { scale: 1 },
hover: { scale: 1.05 },
press: { scale: 0.95 },
};
const arrow: Variants = {
rest: { x: 0 },
hover: { x: 6 },
press: { x: 12 },
};
</script>
<motion.button variants={button} initial="rest" whileHover="hover" whilePress="press">
Continue
<svg viewBox="0 0 24 24">
<motion.path d="M5 12h14M13 6l6 6-6 6" variants={arrow} />
</svg>
</motion.button> <script lang="ts">
import { motion, type Variants } from "motion-sv";
const button: Variants = {
rest: { scale: 1 },
hover: { scale: 1.05 },
press: { scale: 0.95 },
};
const arrow: Variants = {
rest: { x: 0 },
hover: { x: 6 },
press: { x: 12 },
};
</script>
<motion.button
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 24px",
border: "none",
borderRadius: 999,
background: "#ff3e00",
color: "white",
fontSize: 16,
fontWeight: 600,
cursor: "pointer",
}}
variants={button}
initial="rest"
whileHover="hover"
whilePress="press"
>
Continue
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<motion.path d="M5 12h14M13 6l6 6-6 6" stroke-linecap="round" stroke-linejoin="round" variants={arrow} />
</svg>
</motion.button> Gestures
Each gesture below can drive an animation, like this box's whileHover and whilePress:
<script lang="ts">
import { motion } from "motion-sv";
</script>
<div class="example">
<motion.div whileHover={{ scale: 1.2 }} whilePress={{ scale: 0.8 }} class="box" />
</div>
<style>
.example :global(.box) {
width: 100px;
height: 100px;
border-radius: 10px;
background-color: #1e75f7;
}
</style> Hover
The hover gesture detects when a pointer moves onto or off an element.
It differs from pointerenter and pointerleave in that it only fires for real mouse and pen input. Touch screens emulate these events when they're tapped, and hover ignores them.
<motion.a
whileHover={{ scale: 1.2 }}
onHoverStart={(event, info) => console.log("hover start", info.point)}
onHoverEnd={(event, info) => console.log("hover end", info.point)}
/> If the pointer leaves while it's pressed down on the element, the hover ends when the pointer is released.
Press
The press gesture detects when the primary pointer (a left click or the first touch) presses down on an element and is released.
<motion.button whilePress={{ scale: 0.9, rotate: 3 }} /> onPressStart fires when the press starts. When it ends, onPress fires if the pointer was released on the element, and onPressCancel fires if it was released outside it.
<motion.div
whilePress={{ scale: 0.9, rotate: 3 }}
onPressStart={() => (status = "Pressing…")}
onPress={() => (status = "Pressed!")}
onPressCancel={() => (status = "Cancelled")}
>
{status}
</motion.div> <script lang="ts">
import { motion } from "motion-sv";
let status = $state("Press me");
let count = $state(0);
</script>
<div class="press-demo">
<motion.div
class="pad"
whilePress={{ scale: 0.9, rotate: 3 }}
onPressStart={() => (status = "Pressing…")}
onPress={() => {
count++;
status = "Pressed!";
}}
onPressCancel={() => (status = "Cancelled")}
>
{status}
</motion.div>
<p>Presses: {count}. Release outside the pad to cancel.</p>
</div>
<style>
.press-demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.press-demo :global(.pad) {
display: grid;
place-items: center;
width: 160px;
height: 100px;
border-radius: 20px;
background: #9911ff;
color: white;
font-weight: 600;
cursor: pointer;
user-select: none;
}
p {
margin: 0;
font-size: 0.875rem;
opacity: 0.7;
}
</style> By default, a press has to start on the element. With globalPressTarget, a press that starts anywhere on the page counts, and onPress fires wherever it's released.
<motion.div globalPressTarget whilePress={{ opacity: 0.5 }} /> If the pressable element is inside a draggable one and the pointer moves far enough to start a drag, the press doesn't complete: onPress doesn't fire.
Accessibility
Elements with press gestures are keyboard accessible.
Any element with a press prop can receive focus. If it isn't focusable already, like a div, it gets tabindex="0". Once it's focused:
- Pressing
Enterdown triggersonPressStartandwhilePress. - Releasing
EntertriggersonPress. - If the element loses focus before
Enteris released,onPressCancelfires.
Focus
The focus gesture detects when an element gains or loses focus, by the same rules as the CSS :focus-visible selector.
Typically, that's when an input receives focus by any means, or when another element receives focus through accessible means, like keyboard navigation.
<motion.input whileFocus={{ scale: 1.05 }} /> <script lang="ts">
import { motion } from "motion-sv";
</script>
<div class="focus-demo">
<motion.input
placeholder="Click or tab here"
whileFocus={{ scale: 1.05, boxShadow: "0 0 0 3px rgb(255 62 0 / 0.5)" }}
transition={{ type: "spring", visualDuration: 0.2, bounce: 0.4 }}
/>
</div>
<style>
.focus-demo :global(input) {
width: 240px;
padding: 12px 16px;
border: 1px solid rgb(255 62 0 / 0.4);
border-radius: 12px;
outline: none;
background: rgb(255 62 0 / 0.08);
color: inherit;
font: inherit;
}
</style> Pan
The pan gesture recognises when a pointer presses down on an element and moves further than 3 pixels. It ends when the pointer is released.
<motion.div onPan={(event, info) => console.log(info.offset.x, info.offset.y)} /> Pan callbacks receive the pointer event and an info object with:
point: the pointer's position on the page,xandy.delta: how far the pointer moved since the last event.offset: how far the pointer has moved since the pan started.velocity: the pointer's current velocity.
onPanStart fires when the pan starts, onPan on every pointer move, and onPanEnd when the pointer is released. onPanSessionStart fires on the initial pointer down, before the pointer has moved far enough to start a pan.
Pan doesn't move the element by itself, and it has no while prop. Here, onPan sets a motion value that tilts and moves the card, and onPanEnd animates it back:
<script lang="ts">
import { animate, motion, useMotionValue, useTransform, type PanInfo } from "motion-sv";
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-20, 20]);
function onPan(_event: PointerEvent, info: PanInfo) {
x.set(info.offset.x);
}
function onPanEnd() {
animate(x, 0, { type: "spring", bounce: 0.4 });
}
</script>
<motion.div style={{ x, rotate, touchAction: "none" }} {onPan} {onPanEnd} /> <script lang="ts">
import { animate, motion, useMotionValue, useTransform, type PanInfo } from "motion-sv";
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-20, 20]);
let offset = $state({ x: 0, y: 0 });
function onPan(_event: PointerEvent, info: PanInfo) {
offset = info.offset;
x.set(info.offset.x);
}
function onPanEnd() {
animate(x, 0, { type: "spring", bounce: 0.4 });
}
</script>
<div class="pan-demo">
<motion.div class="card" style={{ x, rotate }} {onPan} {onPanEnd}>Pan me</motion.div>
<p>offset: {Math.round(offset.x)}, {Math.round(offset.y)}</p>
</div>
<style>
.pan-demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.pan-demo :global(.card) {
display: grid;
place-items: center;
width: 140px;
height: 180px;
border-radius: 20px;
background: #ff0088;
color: white;
font-weight: 600;
cursor: grab;
touch-action: none;
user-select: none;
}
p {
margin: 0;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
}
</style> Drag
The drag gesture applies pointer movement to the element's x and y:
<motion.div drag whileDrag={{ scale: 1.2, backgroundColor: "#f00" }} /> Set drag to "x" or "y" to drag along one axis only:
<motion.div drag="x" /> Draggable elements disable text selection and touch scrolling on the axes they drag, so they work on touch screens out of the box. Dragging doesn't start from an input, select or textarea inside the element, so those stay usable.
When the drag ends, the element keeps moving with the velocity it was released at, and decelerates with an inertia animation:
<script lang="ts">
import { motion } from "motion-sv";
</script>
<div class="container">
<motion.div drag class="box" />
</div>
<style>
.container {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
}
.container :global(.box) {
width: 100px;
height: 100px;
border-radius: 10px;
background-color: #8df0cc;
}
</style> Disable this by setting dragMomentum to false, or configure it with dragTransition, which accepts inertia options:
<motion.div drag dragTransition={{ bounceStiffness: 600, bounceDamping: 10 }} /> Like pan, drag callbacks receive the pointer event and an info object with point, delta, offset and velocity:
<motion.div
drag
onDragStart={(event, info) => console.log("start", info.point)}
onDrag={(event, info) => console.log("drag", info.offset)}
onDragEnd={(event, info) => console.log("end", info.velocity)}
onDragTransitionEnd={() => console.log("momentum finished")}
/> Constraints
dragConstraints limits how far the element can be dragged. It can be an object of top, left, right and bottom values, in pixels, relative to the element's starting position:
<motion.div drag="x" dragConstraints={{ left: 0, right: 300 }} /> Or it can be another element, which the draggable element has to stay within. Get the element with bind:this:
<script lang="ts">
import { motion } from "motion-sv";
let constraints = $state<HTMLElement>();
</script>
<div bind:this={constraints}>
<motion.div drag dragConstraints={constraints} />
</div> When the constraining element or the draggable element changes size, or the window is resized, Motion measures the constraints again.
By default, the element can be dragged past its constraints with some elasticity, and springs back when released. dragElastic sets how much: 0 means no movement past the constraints, and 1 means full movement. It defaults to 0.35, and can also be an object of top, left, right and bottom values.
<motion.div drag dragConstraints={constraints} dragElastic={0.2} /> <script lang="ts">
import { motion } from "motion-sv";
let constraints = $state<HTMLElement>();
</script>
<div bind:this={constraints} class="constraints">
<motion.div drag dragConstraints={constraints} dragElastic={0.2} class="box" />
</div>
<style>
.constraints {
width: 300px;
height: 300px;
border-radius: 10px;
background-color: #ff008844;
}
.constraints :global(.box) {
width: 100px;
height: 100px;
border-radius: 10px;
background-color: #ff0088;
}
</style> Direction locking
dragDirectionLock locks the element to the first axis it's dragged along. Each time a drag starts, Motion detects the direction the pointer moves in, and the element only drags along that axis. onDirectionLock receives the axis once it's detected.
<motion.div drag dragDirectionLock onDirectionLock={(axis) => console.log(axis)} /> <script lang="ts">
import { motion } from "motion-sv";
let activeDirection = $state<"x" | "y" | null>(null);
</script>
{#snippet line(direction: "x" | "y")}
<motion.div
initial={false}
animate={{ opacity: activeDirection === direction ? 1 : 0.3 }}
transition={{ duration: 0.1 }}
style={{ rotate: direction === "y" ? 90 : 0 }}
class="guide"
/>
{/snippet}
<div class="example">
{@render line("x")}
{@render line("y")}
<motion.div
drag
dragDirectionLock
onDirectionLock={(direction) => (activeDirection = direction)}
onDragEnd={() => (activeDirection = null)}
dragConstraints={{ top: 0, right: 0, bottom: 0, left: 0 }}
dragTransition={{ bounceStiffness: 500, bounceDamping: 15 }}
dragElastic={0.2}
whileDrag={{ cursor: "grabbing" }}
class="box"
/>
</div>
<style>
.example {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 300px;
height: 300px;
}
.example :global(.box) {
position: absolute;
width: 52px;
height: 52px;
border: 1px solid currentColor;
cursor: grab;
}
.example :global(.guide) {
position: absolute;
width: 300px;
height: 1px;
border-top: 1px dashed currentColor;
}
</style> Snap to origin
dragSnapToOrigin animates the element back to where it started when the drag ends. Set it to "x" or "y" to snap back along one axis only.
<motion.div drag dragSnapToOrigin /> Used with dragConstraints, the element can be dragged within the constraints, and still returns to its origin when released.
Drag controls
By default, a drag starts when the pointer presses down on the draggable element. To start it from somewhere else, like a handle or a video scrubber's track, create drag controls with useDragControls and pass them to dragControls:
<script lang="ts">
import { motion, useDragControls } from "motion-sv";
const controls = useDragControls();
</script>
<div role="presentation" onpointerdown={(event) => controls.start(event)}></div>
<motion.div drag="x" dragControls={controls} dragListener={false} /> dragListener={false} stops the element itself from starting a drag, so it only drags through the controls.
Event propagation
Pointer events bubble up through the DOM, so pressing a child element also presses its motion ancestors. To stop a child from triggering its ancestors' gestures, stop the event's propagation in the capture phase, with onpointerdowncapture:
<motion.div whilePress={{ scale: 0.95 }}>
<button onpointerdowncapture={(event) => event.stopPropagation()}>Press the button</button>
</motion.div> <script lang="ts">
import { motion } from "motion-sv";
</script>
<div class="propagation-demo">
<motion.div class="card" whilePress={{ scale: 0.95 }}>
<p>Press the card</p>
<button onpointerdowncapture={(event) => event.stopPropagation()}>Press the button</button>
</motion.div>
</div>
<style>
.propagation-demo :global(.card) {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1.5rem 2rem;
border-radius: 20px;
background: rgb(255 0 136 / 0.15);
cursor: pointer;
user-select: none;
}
p {
margin: 0;
font-weight: 600;
}
button {
padding: 8px 16px;
border: none;
border-radius: 8px;
background: #ff0088;
color: white;
font: inherit;
cursor: pointer;
}
</style> Drag is the exception, as only one element drags at a time by default: dragging a draggable child doesn't drag its draggable parent. Set dragPropagation on both to let them drag together.
<motion.div drag dragPropagation>
<motion.div drag dragPropagation />
</motion.div> SVG filters
Gestures aren't recognised on SVG filter elements, because they have no physical presence on the page and never receive pointer events.
Instead, add the while props and handlers to a parent, and animate the filter elements with variants:
<motion.svg whileHover="hover">
<filter id="blur">
<motion.feGaussianBlur stdDeviation={0} variants={{ hover: { stdDeviation: 2 } }} />
</filter>
</motion.svg>