# Drag gestures on the web

> Drag gestures are far less common on the web than on mobile. I wanted to see how they feel on the web, so I built this iOS 26-inspired gesture with Motion as an exercise.
>
> Source: https://jakub.kr/work/drag-gesture. Interactive demos are only available on the page.

Drag gestures are far less common on the web than on mobile. I wanted to see how they feel on the web, so I built this iOS 26-inspired gesture with [Motion](https://motion.dev/) as an exercise.

## Structure

In terms of structure, the component is straightforward. We start with the outer container and add `overflow-clip`, which clips the content without making the container scrollable. Inside we have a `div` with the content that handles the gesture.

## Drag gesture

For the gesture, we will be using the `drag` prop from Motion. We start by defining the `x` value using the `useMotionValue` hook, where `x` is how far the draggable row shifts horizontally.

`0` means the row is centered
`positive` values mean the row is shifted to the right&#x20;
`negative` values mean the row is shifted to the left

Motion updates `x` continuously while you drag. You don't need to calculate distances manually.

```tsx title="drag-item.tsx"
const x = useMotionValue(0);

<motion.div drag="x" style={{ x }} />
```

We then pass the `x` value to the `style` prop of the `motion.div` and use `drag="x"` to make the row draggable on the x-axis only.

## Drag configuration

In addition to setting up the drag gesture itself, you can also define its behavior.

```tsx title="drag-item.tsx"
<motion.div
  drag="x"
  dragConstraints={{ left: SNAP_POINTS.LEFT, right: SNAP_POINTS.RIGHT }}
  dragElastic={0.05}
  dragMomentum={false}
  style={{ x }}
/>
```

`dragConstraints` allows you to define the boundaries of the draggable area. In this component, we limit the draggable area to the left and right of the row.

`dragElastic` controls the resistance when dragging past the boundaries. Lower values like `0.05` add very little overshoot, while higher values allow more before snapping back.

Setting `dragMomentum` to `false` removes the fling effect. For swipe actions, you want the row to stop where you decide, not where velocity decides.

## Snap points

When the drag ends, the row needs to snap to either `left`, `right` or `center` depending on the direction.

```ts title="constants.ts"
const SNAP_POINTS = {
    LEFT: -116,
    CENTER: 0,
    RIGHT: 116,
};
```

Based on the `x` value when drag ends, we can determine the snap point. If you don't drag far enough, the row will snap to the center. If you cross the threshold, the row will snap to the nearest side.

```tsx title="drag-item.tsx"
const handleDragEnd = () => {
  const currentX = x.get();

  if (Math.abs(currentX) > POSITION_THRESHOLD) {
    animate(
      x,
      currentX > 0 ? SNAP_POINTS.RIGHT : SNAP_POINTS.LEFT,
      SPRING_CONFIG
    );
  } else {
    animate(x, SNAP_POINTS.CENTER, SPRING_CONFIG);
  }
};
```

`POSITION_THRESHOLD` is the commit point. Anything below it closes; anything above it opens.

```ts title="constants.ts"
export const POSITION_THRESHOLD = 58;
```

Setting `dragMomentum` to `false` is what makes this feel consistent. The row always lands on your snap points instead of overshooting based on velocity.

## Progressive reveal

The original iOS gesture doesn't reveal all actions at once. The first action appears first. The second one only appears when you drag past a certain threshold. The two thresholds I used are `44px` and `88px`.

```tsx title="drag-item.tsx"
const [dragProgress, setDragProgress] = useState(0);

useMotionValueEvent(x, "change", setDragProgress);

```

To do this, compare the motion value against the thresholds.

```tsx title="drag-item.tsx"
{/* First button */}
<ActionButton isVisible={dragProgress > 44} label="Set reminder" />

{/* Second button */}
<ActionButton isVisible={dragProgress > 88} label="Mark as unread" />
```

We could also have this calculation be in percentages, but I like to keep it in pixels so I can easily tweak the values. We then use the `isVisible` prop to animate the button.

```tsx title="action-button.tsx"
<motion.button
  animate={{
    opacity: isVisible ? 1 : 0,
    scale: isVisible ? 1 : 0.5,
  }}
  aria-label={label}
  className={cn(bgColor, "action-button")}
  initial={{ opacity: 0, scale: 0.5 }}
  onClick={onClick}
  onFocus={onFocus}
>
  {icon}
</motion.button>
```

## Indicators

In addition to the drag gesture, there are a couple of other animations in this component, with the first one being the indicators.

We render them conditionally, animate `opacity` and `scale` and wrap them in an `AnimatePresence` to handle the exit animation. Depending on where the indicator renders, we add a corresponding `origin` so it animates in from the correct direction.

```tsx title="drag-item.tsx"
<AnimatePresence>
  {isUnread && (
    <motion.div
      animate={{ opacity: 1, scale: 1 }}
      aria-label="Unread indicator"
      className="indicator origin-bottom-right"
      exit={{ opacity: 0, scale: 0.75 }}
      initial={{ opacity: 0, scale: 0.75 }}
      key="unread-indicator"
      transition={{ type: "spring", duration: 0.3, bounce: 0 }}
    >
      <span className="size-2.5 rounded-full bg-white" />
    </motion.div>
  )}
</AnimatePresence>
```

## Trash button

The trash button uses a `clip-path` overlay and renders different trash icons and background colors depending on the state.

I made a [collection of buttons](/work/clip-path-buttons) like these a while back.

```tsx title="trash-button.tsx"
<motion.button
  animate={{
    opacity: isVisible ? 1 : 0,
    scale: isVisible ? 1 : 0.5,
  }}
  aria-label="Delete Item"
  className="button"
  initial={{ opacity: 0, scale: 0.5 }}
  onClick={onClick}
  onFocus={onFocus}
  type="button"
>
  <div className="hold-overlay absolute action-button">
    <IconTrashPermanently className="size-5.5 text-white" />
  </div>
  <IconTrashCan className="size-5.5 text-red-500" />
</motion.button>
```
