Draggable Snap Button
A button you can drag that springs back and snaps: wherever you drag it, on release a spring snaps it to the nearest snap point — with multiple snap points, arrow-key switching, and a pure spring-back mode.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add draggable-snap-button.enDraggable Snap Button is a React drag-and-snap button component built with Motion and Tailwind CSS. On release it springs to the nearest snap point, so it works as a multi-position toggle, a movable floating button, or a button that springs back when you let go.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<DraggableSnapButton/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- Three-way mode switches such as battery saver, balanced, and performance
- A floating action button (FAB) users can drag and dock in a screen corner
- Switching a tool between left and right positions in a player or editor
- A playful element on a landing page that visitors can drag and watch spring back
- Not a good fit: confirming irreversible actions such as payments or deletions; use Slide to Unlock instead
Installation
npx webberui@latest add https://webberui.com/r/draggable-snap-button.en.jsonOr, once registries are configured in components.json, install it as @webberui/draggable-snap-button.
Usage
import {
DraggableSnapButton,
type SnapPoint,
} from "@/components/ui/draggable-snap-button";
const modes: SnapPoint[] = [
{ x: -100, label: "Battery saver" },
{ x: 0, label: "Balanced" },
{ x: 100, label: "Performance" },
];
<DraggableSnapButton
axis="x"
snapPoints={modes}
defaultIndex={1}
onSnapChange={(index, point) => console.log(index, point.label)}
>
Drag
</DraggableSnapButton>Without snapPoints there is only the origin as a snap point, which makes it a pure spring-back button — wherever you drag it, releasing returns it to its original position:
<DraggableSnapButton dragConstraints={boxRef} dragElastic={0.25}>
Give it a toss
</DraggableSnapButton>Examples
Controlled mode: stay in sync with the rest of the UI
With index set, the parent owns the position; update state in onSnapChange. When the parent changes index, the button snaps there too.
"use client";
import * as React from "react";
import { DraggableSnapButton, type SnapPoint } from "@/components/ui/draggable-snap-button";
const sizes: SnapPoint[] = [
{ x: -80, label: "Small" },
{ x: 0, label: "Medium" },
{ x: 80, label: "Large" },
];
export function FontSizeSwitch() {
const [index, setIndex] = React.useState(1);
return (
<div className="flex flex-col items-center gap-4">
<DraggableSnapButton
axis="x"
snapPoints={sizes}
index={index}
onSnapChange={(i) => setIndex(i)}
aria-label="Font size"
>
{sizes[index].label}
</DraggableSnapButton>
<button onClick={() => setIndex(1)}>Reset to default</button>
</div>
);
}Floating button that docks to four corners
Keep axis at its default both and give each snap point both x and y; on release the button snaps to the nearest corner.
const corners: SnapPoint[] = [
{ x: 0, y: 0, label: "Bottom right" },
{ x: -280, y: 0, label: "Bottom left" },
{ x: 0, y: -200, label: "Top right" },
{ x: -280, y: -200, label: "Top left" },
];
<DraggableSnapButton snapPoints={corners} aria-label="Support">
Support
</DraggableSnapButton>Tune the spring feel
springConfig takes Motion's SpringOptions. Lower stiffness and higher damping give a slower snap without overshoot.
<DraggableSnapButton
axis="y"
snapPoints={[{ y: 0, label: "Expanded" }, { y: 120, label: "Collapsed" }]}
springConfig={{ stiffness: 260, damping: 40, mass: 1 }}
>
Drag to adjust
</DraggableSnapButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
snapPoints | SnapPoint[] | [{ x: 0, y: 0 }] | The list of snap points; on release it snaps to the nearest one. The default of just the origin means pure spring back |
axis | 'both' | 'x' | 'y' | 'both' | Constrain the drag direction: free, horizontal only, or vertical only |
index | number | — | Controlled index of the current snap point; when provided, the position is driven from outside |
defaultIndex | number | 0 | Initial snap-point index in uncontrolled mode |
onSnapChange | (index: number, point: SnapPoint) => void | — | Fires when it settles on a different snap point |
springConfig | SpringOptions | { stiffness: 520, damping: 30, mass: 0.9 } | Spring parameters for the snap back |
dragElastic | number | boolean | 0.16 | Rubber-band elasticity when dragged past the bounds; requires dragConstraints |
dragConstraints | RefObject | { top, left, right, bottom } | — | Drag bounds, forwarded to motion |
className | string | — | Overrides the default styles |
children | ReactNode | — | Button content |
SnapPoint is { x?: number; y?: number; label?: string }, where x / y are offsets (px) relative to the button's original position and an omitted axis counts as 0; label is what assistive technology announces. All other <button> attributes (disabled, aria-label, and so on) are forwarded directly.
How it works
- Dragging and snapping: position is carried by motion's
dragwithdragMomentumturned off to keep the snap-point calculation stable; on release only the draggable axes count, and it finds the nearest snap point and snaps there with a spring - Pure spring back: when
snapPointscontains only the origin, dragging in any direction and releasing returns it to its original position; combiningdragConstraintswithdragElasticgives the rubber-band feel of dragging past the bounds - Controlled and uncontrolled: provide
indexto enter controlled mode, where the position is driven from outside andonSnapChangeis only a request; otherwise it starts fromdefaultIndexand records the snap point itself - Settle notification:
onSnapChangeonly fires when it snaps to a point different from the previous one — springing back to the same snap point does not send it again - Keyboard support: with multiple snap points, arrow keys (←↑ previous, →↓ next) and
Home/Endmove between snap points, and the arrow keys' page scrolling is intercepted - Accessibility: with multiple snap points on a single axis it adds
role="slider"andaria-valuemin/max/now/valuetextfor slider-equivalent semantics; a drag description is attached witharia-describedbyand the current snap point is announced witharia-live="polite" - When the user has "reduce motion" enabled at the system level: the snap still happens in full (the position still moves and functionality is unchanged), and only the spring is swapped for an extremely short linear tween, with the drag-time scaling removed
touch-none+select-noneprevent a touch drag from triggering scrolling or text selection; any in-flight spring is stopped on unmount so animation callbacks cannot fire after it
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so you can import it from a Server Component page as usual. The position is initialised as a motion value at the starting snap point, so it doesn't jump after mounting.
What are snap point coordinates relative to?
A SnapPoint's x and y are offsets (px) from the button's original layout position; an omitted axis counts as 0. On release, only the axes allowed by axis are used to find the nearest point.
How do I change the colour and size?
Pass className to override the default styles, for example "bg-blue-600 px-4 py-2 text-white". Classes are merged with cn(), so later Tailwind classes win over the defaults.
Does it work with keyboards and screen readers?
With multiple snap points, arrow keys move to the previous or next point and Home/End jump to the first and last. On a single axis with multiple points the button gets role="slider" and the matching aria-value* attributes, and the current point is announced via aria-live using its label.
Does it conflict with onClick?
Other button attributes are passed straight through, including onClick and disabled; disabled also turns off dragging. The component only omits onDrag, onDragStart, onDragEnd, and onAnimationStart, whose types clash with Motion's drag events.
Gooey Button
A liquid gooey button: droplets at the bottom fuse through an SVG goo filter, well up to cover the button on hover, and splash out thick droplets at the click point.
Stateful Button
A state-machine button running idle → loading → success / error, switching automatically from an async onClick, with the content crossfading and the width morphing along with it.