Drag

Sheets, swipeable rows, switch thumbs. Drag an element with rubber-band edges; on release it snaps to where the flick takes it, keeping the finger's speed. Works with any element, and with Liquid Blur glass.

On this page

Get started

Drag moves an element with a pointer, along one axis or both. It follows the pointer exactly and stretches like rubber past its bounds. On release, it springs to a snap point, starting at the pointer’s speed, so a flick goes as far as you’d expect. Grab it while it’s moving and it stops under your finger.

  1. Install.

    pnpm add @fluidkit/drag
  2. Lay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline translate, so transform and scale stay free for you.

    <section class="sheet">…</section>
    
    <style>
      /* Laid out fully open; Drag moves it down to the other positions */
      .sheet { position: absolute; top: 12px; left: 0; right: 0; height: 100%; }
    </style>
  3. Create the drag once the element is in the DOM.

    import { createDrag } from "@fluidkit/drag";
    
    const sheet = createDrag({
      element: document.querySelector(".sheet")!,
      axis: "y",
      // Open, half way, collapsed to the title. Recomputed on resize
      snaps: () => [0, innerHeight / 2, innerHeight - 88],
      initial: 1,
    });
    
    // On unmount:
    // sheet.destroy();
  1. Install. The React components are included in the package (React 18+).

    pnpm add @fluidkit/drag
  2. Lay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline translate, so transform and scale stay free for you.

    /* Laid out fully open; Drag moves it down to the other positions */
    .sheet { position: absolute; top: 12px; left: 0; right: 0; height: 100%; }
  3. Add Drag.Root. It’s the element that moves. Snap points can be a function of the parent’s size; it’s called again when the size changes.

    import { Drag } from "@fluidkit/drag/react";
    
    export function Sheet({ children }: { children: React.ReactNode }) {
      return (
        <Drag.Root
          className="sheet"
          axis="y"
          // Open, half way, collapsed to the title
          snaps={({ height }) => [0, height / 2, height - 88]}
          defaultSnap={1}
        >
          {children}
        </Drag.Root>
      );
    }

    Components are marked "use client". On the server they render plain markup at offset 0.

  1. Install. The Vue components are included in the package (Vue 3.5+).

    pnpm add @fluidkit/drag
  2. Lay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline translate, so transform and scale stay free for you.

    /* Laid out fully open; Drag moves it down to the other positions */
    .sheet { position: absolute; top: 12px; left: 0; right: 0; height: 100%; }
  3. Add Drag.Root. It’s the element that moves. Snap points can be a function of the parent’s size; it’s called again when the size changes.

    <script setup lang="ts">
    import { Drag } from "@fluidkit/drag/vue";
    </script>
    
    <template>
      <!-- Open, half way, collapsed to the title -->
      <Drag.Root class="sheet" axis="y" :snaps="({ height }) => [0, height / 2, height - 88]" :default-snap="1">
        <slot />
      </Drag.Root>
    </template>

    On the server, including Nuxt, the components render plain markup at offset 0.

  1. Install. The Svelte components are included in the package (Svelte 5.29+).

    pnpm add @fluidkit/drag
  2. Lay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline translate, so transform and scale stay free for you.

    /* Laid out fully open; Drag moves it down to the other positions */
    .sheet { position: absolute; top: 12px; left: 0; right: 0; height: 100%; }

    A class on a part reaches its element, but a component’s scoped styles don’t: put these in a global stylesheet or :global().

  3. Add Drag.Root. It’s the element that moves. Snap points can be a function of the parent’s size; it’s called again when the size changes.

    <script lang="ts">
      import type { Snippet } from "svelte";
      import { Drag } from "@fluidkit/drag/svelte";
    
      let { children }: { children: Snippet } = $props();
    </script>
    
    <!-- Open, half way, collapsed to the title -->
    <Drag.Root class="sheet" axis="y" snaps={({ height }) => [0, height / 2, height - 88]} defaultSnap={1}>
      {@render children()}
    </Drag.Root>

    On the server, including SvelteKit, the components render plain markup at offset 0.

Examples

A sheet

Three positions: open, half way, and collapsed to the title. A slow drag settles on the nearest one; a hard flick can go from the bottom all the way to the top. Past the ends it stretches like rubber.

The list inside scrolls, and one finger gesture can move both, like in iOS: push up from half way and the sheet opens first, then the list scrolls. Pull down and the list scrolls back to the top before the sheet moves. See A list inside.

Nearby

  • Blue Bottle CoffeeCafé · 4 min walk
  • Tartine BakeryBakery · 7 min walk
  • Dolores ParkPark · 9 min walk
  • Gracias MadreRestaurant · 12 min walk
  • Mission DoloresLandmark · 13 min walk
  • Bi-Rite CreameryIce cream · 14 min walk
  • Ritual CoffeeCafé · 15 min walk
  • Alamo SquarePark · 18 min walk
  • Golden Gate ParkPark · 24 min walk
  • Twin PeaksViewpoint · 31 min walk

Swipe actions

Rows like in Mail: swipe right to mark as read, left to flag or delete. Swipe all the way and the outer action fills the row and runs on release. axis: "x" leaves vertical scrolling to the browser, so you can still scroll the list. There are five snap points, from fully right to fully left. A lower deceleration makes a full swipe need a real pull, not just a flick.

  • AnaTickets for Friday are in your inbox
  • StudioYour export is ready to download
  • LeoLunch tomorrow? The new place on 5th

A switch

The thumb is the dragged element and the whole switch is its handle. Drag it and it goes to the side the flick points to; tap it to toggle. rubberband: 0 keeps it inside the track. A short follow spring makes it trail the pointer slightly, so quick back-and-forth movement looks smooth. The thumb is glass: solid white at rest, and a lens while held, using Liquid Blur’s --lb-lens and --lb-transparency on data-dragging.

  • Wi-Fi
  • Bluetooth
  • Airplane Mode

A slider

The knob is the dragged element and the whole slider is its handle. It’s limited by bounds and has no snap points, so it stays where you release it; deceleration: 0 stops flicks from carrying it further. While held, it becomes a lens that stretches only horizontally (--lb-stretch-axis: x). Tap the track to jump there; arrow keys step it.

Picture in picture

A video that moves on both axes and snaps to the four corners. Throw it and it goes to the corner the throw points to, not the nearest one. Double-tap to resize; the corners are computed from its size and recomputed when it changes, so it stays in its corner.

Snapping

On release, Drag doesn’t pick the snap point closest to where you let go. It estimates where the element would stop if it kept sliding and slowed down like a scroll, and picks the snap point closest to that. A slow drag stays near; a flick can skip past points in between. iOS picks sheet positions and picture-in-picture corners the same way.

deceleration controls how fast that imagined slide slows down, per millisecond. The default 0.998 matches scrolling; 0.99 stops sooner, so flicks reach less far. Without snap points, the element stops where the slide would end, within its bounds.

The release spring starts at the pointer’s speed. The default spring has no bounce, but a hard flick still overshoots once and comes back, like iOS sheets. Overshooting past the bounds stretches like rubber; with rubberband: 0 it stops at the edge.

OptionsTypeScript

createDrag(options: DragOptions): Drag

Option Description
element: HTMLElement The element to move. Drag owns its inline translate
handle?: HTMLElement The element to grab, like a sheet’s grabber or a whole switch. Default: element
axis?: "x" | "y" | "both" Direction of movement. Default: both
scroll?: HTMLElement A scrollable area inside, like a sheet’s list. One gesture can scroll it and move the element. x or y axis only
snaps?: DragSnaps | () => DragSnaps Where it can come to rest: offsets along the axis, or { x, y } points for both. A function is called again when the element, its parent or the window resizes
bounds?: DragBounds | () => DragBounds { x?: [min, max], y?: [min, max] }. Past these it stretches like rubber. Default: the outermost snap points, or no limit without snaps
initial?: number Index of the starting snap point. Default: 0
rubberband?: number Resistance past the bounds. Default: 0.55, as in iOS. 0 stops at the edge
deceleration?: number How fast a flick slows down, per millisecond. Default: 0.998
spring?: SpringParams The release spring. Default: { duration: 0.4, bounce: 0 }. See core
follow?: SpringParams If set, the element trails the pointer on this spring instead of sticking to it exactly
onStart?: () => void Called when a drag starts, or when the element is grabbed mid-motion
onMove?: (position, velocity) => void Called whenever it moves, with its offset and speed in px/s. Use it to sync other things, like a backdrop’s opacity
onEnd?: (snap) => void Called on release with the target snap point (null without snaps)
onRest?: () => void Called when it stops moving

APITypeScript

Member Description
position Current offset, { x, y }
velocity Current speed in px/s, { x, y }
dragging true while a pointer is dragging it
moving true while dragged or animating
current Index of the current or target snap point. null after to(), or without snaps
disabled Set to true to stop dragging. An active drag is released
snap(index, move?) Animates to a snap point. { instant: true } jumps there; { velocity } sets the starting speed
to(position, move?) Animates to an offset within the bounds: a number along the axis, or { x, y }
update() Recomputes snap points and bounds, and keeps the same snap point
set(settings) Updates any option except element, handle, axis, scroll and initial, without recreating the drag
destroy() Stops it, removes listeners, and restores the element’s translate and the handle’s touch-action

PartsReactVueSvelte

Part Renders Description
Drag.Root div The element that moves. Has data-snap with the current snap point and data-dragging while dragged
Drag.Handle div The part you grab, like a sheet’s grabber. Without it, the whole root is the handle
Drag.Scroll div A scrollable area inside, like a sheet’s list. One gesture can scroll it and move the root

Drag.Root props:

Prop Description
axis?: "x" | "y" | "both" Direction of movement. Default: both
snaps?: DragSnaps | (area) => DragSnaps Where it can come to rest: offsets along the axis, or { x, y } points for both. A function receives { width, height, element } (the parent’s inner size and the element), and is called again on resize and on re-render
bounds?: DragBounds | (area) => DragBounds { x?: [min, max], y?: [min, max] }. Past these it stretches like rubber. Default: the outermost snap points
snap?: number Current snap point, for controlled use
defaultSnap?: number Initial snap point, for uncontrolled use. Default: 0
onSnapChange?: (snap) => void Called when it’s released toward a different snap point
disabled?: boolean Disables dragging. An active drag is released
rubberband?: number Resistance past the bounds. Default: 0.55. 0 stops at the edge
deceleration?: number How fast a flick slows down, per millisecond. Default: 0.998
spring?: SpringParams The release spring. Default: { duration: 0.4, bounce: 0 }. See core
follow?: SpringParams If set, the element trails the pointer on this spring
onStart?: () => void Called when a drag starts, or when the element is grabbed mid-motion
onMove?: (position, velocity) => void Called every frame it moves. Update the DOM directly here; setting state would re-render every frame
onEnd?: (snap) => void Called on release with the target snap point (null without snaps)
onRest?: () => void Called when it stops moving

Controlled. Pass snap and onSnapChange to keep the position in your state. If you don’t accept the new value, it goes back to snap.

const [snap, setSnap] = useState(1);
<Drag.Root snap={snap} onSnapChange={setSnap}>…</Drag.Root>
<button onClick={() => setSnap(0)}>Open</button>

Changing props never recreates the drag, and callbacks always see the latest values. Changing axis, or adding a handle or scroll area, recreates it at the same position.

Part Renders Description
Drag.Root div The element that moves. Has data-snap with the current snap point and data-dragging while dragged
Drag.Handle div The part you grab, like a sheet’s grabber. Without it, the whole root is the handle
Drag.Scroll div A scrollable area inside, like a sheet’s list. One gesture can scroll it and move the root

Every part also takes as-child, see Composition.

Drag.Root props and events:

Prop or event Description
axis?: "x" | "y" | "both" Direction of movement. Default: both
snaps?: DragSnaps | (area) => DragSnaps Where it can come to rest: offsets along the axis, or { x, y } points for both. A function receives { width, height, element } (the parent’s inner size and the element), and is called again on resize and when what it uses changes
bounds?: DragBounds | (area) => DragBounds { x?: [min, max], y?: [min, max] }. Past these it stretches like rubber. Default: the outermost snap points
v-model:snap The current snap point. Without it, the root keeps its own, starting at defaultSnap
defaultSnap?: number Initial snap point, for uncontrolled use. Default: 0
disabled?: boolean Disables dragging. An active drag is released
rubberband?: number Resistance past the bounds. Default: 0.55. 0 stops at the edge
deceleration?: number How fast a flick slows down, per millisecond. Default: 0.998
spring?: SpringParams The release spring. Default: { duration: 0.4, bounce: 0 }. See core
follow?: SpringParams If set, the element trails the pointer on this spring
@start A drag started, or the element was grabbed mid-motion
@move="(position, velocity) => …" Every frame it moves. Update the DOM directly here; writing to reactive state would re-render every frame
@end="(snap) => …" Released, with the target snap point (null without snaps)
@rest It stopped moving

Controlled. With v-model:snap, a release updates your ref. To accept or refuse it yourself, pass :snap and handle @update:snap; if you don’t change snap, it goes back.

<Drag.Root v-model:snap="snap">…</Drag.Root>
<button @click="snap = 0">Open</button>

Changing props never recreates the drag. Changing axis, or adding a handle or scroll area, recreates it at the same position.

Part Renders Description
Drag.Root div The element that moves. Has data-snap with the current snap point and data-dragging while dragged
Drag.Handle div The part you grab, like a sheet’s grabber. Without it, the whole root is the handle
Drag.Scroll div A scrollable area inside, like a sheet’s list. One gesture can scroll it and move the root

Every part also takes a child snippet, see Composition.

Drag.Root props:

Prop Description
axis?: "x" | "y" | "both" Direction of movement. Default: both
snaps?: DragSnaps | (area) => DragSnaps Where it can come to rest: offsets along the axis, or { x, y } points for both. A function receives { width, height, element } (the parent’s inner size and the element), and is called again on resize and when what it uses changes
bounds?: DragBounds | (area) => DragBounds { x?: [min, max], y?: [min, max] }. Past these it stretches like rubber. Default: the outermost snap points
bind:snap The current snap point. Without it, the root keeps its own, starting at defaultSnap
defaultSnap?: number Initial snap point, for uncontrolled use. Default: 0
onSnapChange={(snap) => …} Released heading for another snap point
disabled?: boolean Disables dragging. An active drag is released
rubberband?: number Resistance past the bounds. Default: 0.55. 0 stops at the edge
deceleration?: number How fast a flick slows down, per millisecond. Default: 0.998
spring?: SpringParams The release spring. Default: { duration: 0.4, bounce: 0 }. See core
follow?: SpringParams If set, the element trails the pointer on this spring
onStart={() => …} A drag started, or the element was grabbed mid-motion
onMove={(position, velocity) => …} Every frame it moves. Update the DOM directly here; writing to reactive state would update the page every frame
onEnd={(snap) => …} Released, with the target snap point (null without snaps)
onRest={() => …} It stopped moving

Controlled. With bind:snap, a release updates your state. To accept or refuse it yourself, bind a getter and a setter: bind:snap={() => snap, (next) => allowed(next) && (snap = next)}; if snap doesn’t change, it goes back.

<Drag.Root bind:snap>…</Drag.Root>
<button onclick={() => (snap = 0)}>Open</button>

Changing props never recreates the drag. Changing axis, or adding a handle or scroll area, recreates it at the same position.

CompositionReactVueSvelte

Render as your own element. Every part accepts render, as in Base UI: pass an element and our props are merged into it, or a function that receives the props and state ({ snap, dragging }).

<Drag.Root axis="y" snaps={snaps} render={<section aria-label="Nearby" />}>
  <Drag.Scroll render={<ul className="list" />}>{rows}</Drag.Scroll>
</Drag.Root>

Glass. Make it glass with className="lb", or render it as Glass. Drag only writes translate, so press effects keep working.

import { Glass } from "@fluidkit/blur/react";

<Drag.Root axis="y" snaps={snaps} render={<Glass render={<section className="sheet" />} />}>…</Drag.Root>

Handle outside the element. With parts, the handle sits inside the moving element. For a switch or slider it’s the other way round (the thumb is inside the handle), so use the hook, as the examples above do.

Styling. data-snap on the root shows the current snap point, data-dragging is there while it’s dragged.

Render as your own element. Every part accepts as-child, as in Reka UI: instead of its own element, the part renders the first element in its slot, with our attributes, classes, styles, listeners and refs merged in. The slot also receives the part’s state ({ snap, dragging }).

<Drag.Root axis="y" :snaps="snaps" as-child>
  <section aria-label="Nearby">
    <Drag.Scroll as-child><ul class="list">…</ul></Drag.Scroll>
  </section>
</Drag.Root>

Glass. Make it glass with class="lb", or render it as Glass. Drag only writes translate, so press effects keep working.

<Drag.Root axis="y" :snaps="snaps" as-child>
  <Glass as-child><section class="sheet">…</section></Glass>
</Drag.Root>

Handle outside the element. With parts, the handle sits inside the moving element. For a switch or slider it’s the other way round (the thumb is inside the handle), so use the composable, as the examples above do.

Styling. data-snap on the root shows the current snap point, data-dragging is there while it’s dragged.

Render as your own element. Every part accepts a child snippet, as in bits-ui: instead of its own element, the part renders yours. Spread the props it gives onto your element: they carry our attributes, handlers and the attachment that connects it. The snippet also receives the part’s state ({ snap, dragging }).

<Drag.Root axis="y" {snaps}>
  {#snippet child({ props })}
    <section {...props} aria-label="Nearby">
      <Drag.Scroll>
        {#snippet child({ props })}<ul {...props} class="list">…</ul>{/snippet}
      </Drag.Scroll>
    </section>
  {/snippet}
</Drag.Root>

Glass. Make it glass with class="lb", or render it as Glass. Drag only writes translate, so press effects keep working.

<Drag.Root axis="y" {snaps}>
  {#snippet child({ props: drag })}
    <Glass {...drag}>
      {#snippet child({ props })}<section {...props} class="sheet">…</section>{/snippet}
    </Glass>
  {/snippet}
</Drag.Root>

Handle outside the element. With parts, the handle sits inside the moving element. For a switch or slider it’s the other way round (the thumb is inside the handle), so use the function, as the examples above do.

Styling. data-snap on the root shows the current snap point, data-dragging is there while it’s dragged.

The hookReactVueSvelte

Use useDrag(options) when the parts don’t fit your markup. It takes the same options as Drag.Root and returns refs.

import { useDrag } from "@fluidkit/drag/react";

const drag = useDrag({ axis: "x", snaps: ({ width, element }) => [0, width - element.offsetWidth] });

<div ref={drag.handle} className="track">
  <span ref={drag.element} className="thumb" />
</div>
Member Description
element, handle, scroll Refs for the moving element, the handle and the scroll area
snap Current snap point
dragging true while dragged
snapTo(index, move?) Animates to a snap point. { instant: true } jumps there; { velocity } sets the starting speed
to(position, move?) Animates to an offset within the bounds
drag The underlying engine, once mounted: position, velocity, update()

Use useDrag(options) when the parts don’t fit your markup. Options can be an object, a ref or a getter; a getter is read again when what it uses changes. It returns function refs for :ref.

<script setup lang="ts">
import { useDrag } from "@fluidkit/drag/vue";

const drag = useDrag({ axis: "x", snaps: ({ width, element }) => [0, width - element.offsetWidth] });
</script>

<template>
  <div :ref="drag.handle" class="track">
    <span :ref="drag.element" class="thumb" />
  </div>
</template>
Member Description
element, handle, scroll Function refs for the moving element, the handle and the scroll area
snap Computed: the current snap point
dragging Ref: true while dragged
snapTo(index, move?) Animates to a snap point. { instant: true } jumps there; { velocity } sets the starting speed
to(position, move?) Animates to an offset within the bounds
drag Shallow ref to the underlying engine, once mounted: position, velocity, update()

To put two refs on one element (yours and the drag’s), use one function that calls both, as the switch above does.

Use useDrag(() => options) when the parts don’t fit your markup. Call it in a component’s script; the options getter is read again when what it uses changes. It returns attachments for {@attach}, and state you read like any other.

<script lang="ts">
  import { useDrag } from "@fluidkit/drag/svelte";

  const drag = useDrag(() => ({ axis: "x", snaps: ({ width, element }) => [0, width - element.offsetWidth] }));
</script>

<div {@attach drag.handle} class="track">
  <span {@attach drag.element} class="thumb"></span>
</div>
Member Description
element, handle, scroll Attachments for the moving element, the handle and the scroll area
snap The current snap point
dragging true while dragged
snapTo(index, move?) Animates to a snap point. { instant: true } jumps there; { velocity } sets the starting speed
to(position, move?) Animates to an offset within the bounds
drag The underlying engine, once mounted: position, velocity, update()

An element can take the drag’s attachment and your own bind:this side by side, as the switch above does.

The gesture

Starting

A drag starts after the pointer moves 4px (8px on touch), so taps stay taps. With one axis, moving across it first is left to the page: you can scroll a list past rows that swipe sideways. With nested handles, like swipeable rows inside a sheet, the innermost one wins.

Touch

Drag sets the handle’s touch-action so the browser keeps the other axis: pan-y for x, pan-x for y, none for both. Pinch-zoom still works. Once a drag starts, the page doesn’t scroll under the finger.

Clicks

The click at the end of a drag is cancelled, so dragging a button doesn’t press it, while tapping does. Drags don’t start on text fields, and links and images in the handle can’t be dragged out by the browser.

Measuring

Offsets are in the element’s own pixels, so it tracks the pointer in a scaled container too. While dragged, it has data-dragging, and text on the page can’t be selected. With reduced motion, it still follows the pointer but snaps instantly on release.

Roles, labels and keyboard support are up to you: a sheet’s dialog role, a switch’s role="switch" and click handler, arrow keys on a slider. Drag only handles movement.

A list inside

A sheet with a scrollable list can share one gesture between the two, like in iOS. Pass the list as scroll (in React, Vue and Svelte, use Drag.Scroll). For touch:

What moves

The sheet moves when you pull it toward later snap points (down, for a bottom sheet) while the list is at the top, or push it the other way while it isn’t fully open. Otherwise the list scrolls. Outside the list, on the grabber or title, the sheet always moves.

Within one gesture

Push the sheet fully open and the rest of the gesture scrolls the list. Pull down and the list scrolls back to the top, then the sheet follows. In Chrome, a scroll in progress can’t be interrupted, so the sheet moves on the next pull.

With a mouse, dragging moves the sheet and the wheel scrolls the list. Add overscroll-behavior: contain to the list so scrolling doesn’t continue to the page. scroll works only with the x or y axis.

With Liquid Blur

The element can be glass, like the sheet and the video above. Drag only writes translate, so Liquid Blur’s press effects keep their scale and transform.

Browser support

Any browser with ES2022, Pointer Events, ResizeObserver and the translate property.

Limitations

  • Rotated ancestors aren’t supported; scaled ones are.
  • If snap points depend on something other than the element, its parent or the window size, call update() when it changes.
PlaygroundIts hard cases, live: slowed down, on other backdrops, with their boxes drawn.