Buttons & Toggles

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.en

New project or installation trouble? Start here →

Draggable 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.

0.16
Active edited usageDefault props
<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.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>

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

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
indexnumber—Controlled 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) => void—Fires 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
classNamestring—Overrides the default styles
childrenReactNode—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 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

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.