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.
npx shadcn@latest add https://webberui.com/r/draggable-snap-button.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<DraggableSnapButton />
Installation
npx shadcn@latest add https://webberui.com/r/draggable-snap-button.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>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
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.