WebberUI

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.

Loading preview…
npx shadcn@latest add https://webberui.com/r/draggable-snap-button.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

0.16
<DraggableSnapButton />

Installation

npx shadcn@latest add https://webberui.com/r/draggable-snap-button.json

Or, 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

PropTypeDefaultDescription
snapPointsSnapPoint[][{ 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
indexnumberControlled index of the current snap point; when provided, the position is driven from outside
defaultIndexnumber0Initial snap-point index in uncontrolled mode
onSnapChange(index: number, point: SnapPoint) => voidFires when it settles on a different snap point
springConfigSpringOptions{ stiffness: 520, damping: 30, mass: 0.9 }Spring parameters for the snap back
dragElasticnumber | boolean0.16Rubber-band elasticity when dragged past the bounds; requires dragConstraints
dragConstraintsRefObject | { top, left, right, bottom }Drag bounds, forwarded to motion
classNamestringOverrides the default styles
childrenReactNodeButton 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 drag with dragMomentum turned 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 snapPoints contains only the origin, dragging in any direction and releasing returns it to its original position; combining dragConstraints with dragElastic gives the rubber-band feel of dragging past the bounds
  • Controlled and uncontrolled: provide index to enter controlled mode, where the position is driven from outside and onSnapChange is only a request; otherwise it starts from defaultIndex and records the snap point itself
  • Settle notification: onSnapChange only 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 / End move between snap points, and the arrow keys' page scrolling is intercepted
  • Accessibility: with multiple snap points on a single axis it adds role="slider" and aria-valuemin/max/now/valuetext for slider-equivalent semantics; a drag description is attached with aria-describedby and the current snap point is announced with aria-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-none prevent a touch drag from triggering scrolling or text selection; any in-flight spring is stopped on unmount so animation callbacks cannot fire after it

On this page