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.
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.
Install.
pnpm add @fluidkit/dragLay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline
translate, sotransformandscalestay 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>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();
Install. The React components are included in the package (React 18+).
pnpm add @fluidkit/dragLay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline
translate, sotransformandscalestay 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%; }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.
Install. The Vue components are included in the package (Vue 3.5+).
pnpm add @fluidkit/dragLay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline
translate, sotransformandscalestay 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%; }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.
Install. The Svelte components are included in the package (Svelte 5.29+).
pnpm add @fluidkit/dragLay out the element at its starting position. Snap points and bounds are pixel offsets from there. Drag moves the element with inline
translate, sotransformandscalestay 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().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
<div class="screen">
<section class="sheet lb" aria-label="Nearby">
<span class="sheet__grabber" aria-hidden="true"></span>
<h3>Nearby</h3>
<ul class="sheet__list">…</ul>
</section>
</div>import "@fluidkit/blur/style.css";
import { createDrag } from "@fluidkit/drag";
const screen = document.querySelector<HTMLElement>(".screen")!;
const sheet = screen.querySelector<HTMLElement>(".sheet")!;
createDrag({
element: sheet,
axis: "y",
// One gesture can scroll the list and move the sheet
scroll: sheet.querySelector<HTMLElement>(".sheet__list")!,
// Offsets from the laid-out position (12px from the top): open, half way, collapsed
snaps: () => {
const h = screen.clientHeight;
return [0, h / 2 - 12, h - 12 - 76];
},
initial: 1,
});import "@fluidkit/blur/style.css";
import { Drag } from "@fluidkit/drag/react";
export function Nearby({ places }: { places: Place[] }) {
return (
<div className="screen">
<Drag.Root
axis="y"
// Offsets from the laid-out position (12px from the top): open, half way, collapsed
snaps={({ height }) => [0, height / 2 - 12, height - 12 - 76]}
defaultSnap={1}
className="sheet lb"
aria-label="Nearby"
render={<section />}
>
<span className="sheet__grabber" aria-hidden="true" />
<h3>Nearby</h3>
{/* One gesture can scroll the list and move the sheet */}
<Drag.Scroll render={<ul className="sheet__list" />}>
{places.map((place) => (
<li key={place.id}>{place.name}</li>
))}
</Drag.Scroll>
</Drag.Root>
</div>
);
}<script setup lang="ts">
import "@fluidkit/blur/style.css";
import { Drag } from "@fluidkit/drag/vue";
defineProps<{ places: { id: string; name: string }[] }>();
</script>
<template>
<div class="screen">
<!-- Offsets from the laid-out position (12px from the top): open, half way, collapsed -->
<Drag.Root axis="y" :snaps="({ height }) => [0, height / 2 - 12, height - 12 - 76]" :default-snap="1" as-child>
<section class="sheet lb" aria-label="Nearby">
<span class="sheet__grabber" aria-hidden="true" />
<h3>Nearby</h3>
<!-- One gesture can scroll the list and move the sheet -->
<Drag.Scroll as-child>
<ul class="sheet__list">
<li v-for="place in places" :key="place.id">{{ place.name }}</li>
</ul>
</Drag.Scroll>
</section>
</Drag.Root>
</div>
</template><script lang="ts">
import "@fluidkit/blur/style.css";
import { Drag } from "@fluidkit/drag/svelte";
let { places }: { places: { id: string; name: string }[] } = $props();
</script>
<div class="screen">
<!-- Offsets from the laid-out position (12px from the top): open, half way, collapsed -->
<Drag.Root axis="y" snaps={({ height }) => [0, height / 2 - 12, height - 12 - 76]} defaultSnap={1}>
{#snippet child({ props })}
<section {...props} class="sheet lb" aria-label="Nearby">
<span class="sheet__grabber" aria-hidden="true"></span>
<h3>Nearby</h3>
<!-- One gesture can scroll the list and move the sheet -->
<Drag.Scroll>
{#snippet child({ props })}
<ul {...props} class="sheet__list">
{#each places as place (place.id)}<li>{place.name}</li>{/each}
</ul>
{/snippet}
</Drag.Scroll>
</section>
{/snippet}
</Drag.Root>
</div>.screen { position: relative; height: 460px; overflow: clip; border-radius: 20px; }
/* Taller than the screen, so its bottom edge never shows when pulled up */
.sheet {
position: absolute;
top: 12px;
left: 10px;
right: 10px;
height: calc(100% + 160px);
display: flex;
flex-direction: column;
/* When open, the list ends at the bottom of the screen */
padding: 8px 20px 172px;
border-radius: 28px;
cursor: grab;
user-select: none;
}
.sheet[data-dragging] { cursor: grabbing; }
/* Don't scroll the page at the ends */
.sheet__list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }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
<ul class="inbox lb">
<li class="row">
<div class="row__lead" aria-hidden="true">
<button type="button" class="unread" tabindex="-1">Unread</button>
</div>
<div class="row__trail" aria-hidden="true">
<button type="button" class="flag" tabindex="-1">Flag</button>
<button type="button" class="delete" tabindex="-1">Delete</button>
</div>
<div class="row__face">…</div>
</li>
…
</ul>import { createDrag, type Drag } from "@fluidkit/drag";
const LEAD = 92;
const TRAIL = 168;
// Snap points: fully right, left action, closed, right actions, fully left
const ALL_RIGHT = 0;
const CLOSED = 2;
const ALL_LEFT = 4;
const drags = new Map<HTMLElement, Drag>();
// remove(row) and toggleUnread(row) are your own functions
for (const row of document.querySelectorAll<HTMLElement>(".row")) {
const face = row.querySelector<HTMLElement>(".row__face")!;
const drag = createDrag({
element: face,
axis: "x",
snaps: () => [row.clientWidth, LEAD, 0, -TRAIL, -row.clientWidth],
initial: CLOSED,
deceleration: 0.99,
onStart: () => {
for (const [other, d] of drags) if (other !== row && d.current !== CLOSED) d.snap(CLOSED);
},
onMove: ({ x }) => {
// Width for the actions; past half way to the edge, the outer action fills the row
row.style.setProperty("--x", String(x));
const w = row.clientWidth;
row.toggleAttribute("data-full", x < -(TRAIL + w) / 2 || x > (LEAD + w) / 2);
},
// A full swipe runs the action on release
onEnd: (snap) => {
if (snap === ALL_LEFT) remove(row);
if (snap === ALL_RIGHT) {
toggleUnread(row);
// Then slide back closed
queueMicrotask(() => drag.snap(CLOSED));
}
},
});
drags.set(row, drag);
// Drags don't fire clicks, so this is a tap
face.addEventListener("click", () => drag.current !== CLOSED && drag.snap(CLOSED));
row.querySelector(".delete")!.addEventListener("click", () => {
drag.snap(ALL_LEFT);
remove(row);
});
// Unread and Flag: run the action, then drag.snap(CLOSED)
}import { useRef, useState } from "react";
import { Drag } from "@fluidkit/drag/react";
const LEAD = 92;
const TRAIL = 168;
// Snap points: fully right, left action, closed, right actions, fully left
const ALL_RIGHT = 0;
const CLOSED = 2;
const ALL_LEFT = 4;
export function Inbox({ mails }: { mails: Mail[] }) {
// Which row is open, and how far. Only one at a time
const [open, setOpen] = useState<{ id: string; snap: number } | null>(null);
return (
<ul className="inbox lb">
{mails.map((mail) => (
<Row
key={mail.id}
mail={mail}
snap={open?.id === mail.id ? open.snap : CLOSED}
onSnapChange={(snap) => setOpen(snap === CLOSED ? null : { id: mail.id, snap })}
// Starting to swipe another row closes this one
onStart={() => setOpen((o) => (o?.id === mail.id ? o : null))}
/>
))}
</ul>
);
}
type RowProps = { mail: Mail; snap: number; onSnapChange: (snap: number) => void; onStart: () => void };
function Row({ mail, snap, onSnapChange, onStart }: RowProps) {
const row = useRef<HTMLLIElement>(null);
// remove(mail) and toggleUnread(mail) are your own functions
return (
<li ref={row} className="row">
<div className="row__lead" aria-hidden="true">
<button
type="button"
className="unread"
tabIndex={-1}
onClick={() => {
toggleUnread(mail);
onSnapChange(CLOSED);
}}
>
Unread
</button>
</div>
<div className="row__trail" aria-hidden="true">
<button type="button" className="flag" tabIndex={-1} onClick={() => onSnapChange(CLOSED)}>
Flag
</button>
<button
type="button"
className="delete"
tabIndex={-1}
onClick={() => {
onSnapChange(ALL_LEFT);
remove(mail);
}}
>
Delete
</button>
</div>
<Drag.Root
className="row__face"
axis="x"
snaps={({ width }) => [width, LEAD, 0, -TRAIL, -width]}
snap={snap}
deceleration={0.99}
onStart={onStart}
onMove={({ x }) => {
// Width for the actions; past half way to the edge, the outer action fills the row
const el = row.current!;
el.style.setProperty("--x", String(x));
el.toggleAttribute("data-full", x < -(TRAIL + el.clientWidth) / 2 || x > (LEAD + el.clientWidth) / 2);
}}
// A full swipe runs the action on release; after marking, slide back closed
onSnapChange={(next) => {
if (next === ALL_LEFT) remove(mail);
if (next === ALL_RIGHT) toggleUnread(mail);
onSnapChange(next === ALL_RIGHT ? CLOSED : next);
}}
// Drags don't fire clicks, so this is a tap
onClick={() => onSnapChange(CLOSED)}
>
…
</Drag.Root>
</li>
);
}<script setup lang="ts">
import { ref } from "vue";
import { Drag } from "@fluidkit/drag/vue";
type Mail = { id: string; subject: string };
defineProps<{ mails: Mail[] }>();
const LEAD = 92;
const TRAIL = 168;
// Snap points: fully right, left action, closed, right actions, fully left
const ALL_RIGHT = 0;
const CLOSED = 2;
const ALL_LEFT = 4;
// remove(mail) and toggleUnread(mail) are your own functions
// Which row is open, and how far. Only one at a time
const open = ref<{ id: string; snap: number } | null>(null);
const rows = new Map<string, HTMLElement>();
const snapOf = (mail: Mail) => (open.value?.id === mail.id ? open.value.snap : CLOSED);
const setSnap = (mail: Mail, snap: number) => (open.value = snap === CLOSED ? null : { id: mail.id, snap });
// Starting to swipe another row closes this one
const start = (mail: Mail) => open.value?.id !== mail.id && (open.value = null);
// Width for the actions; past half way to the edge, the outer action fills the row
const follow = (mail: Mail, x: number) => {
const row = rows.get(mail.id)!;
row.style.setProperty("--x", String(x));
row.toggleAttribute("data-full", x < -(TRAIL + row.clientWidth) / 2 || x > (LEAD + row.clientWidth) / 2);
};
// A full swipe runs the action on release; after marking, slide back closed
const land = (mail: Mail, snap: number) => {
if (snap === ALL_LEFT) remove(mail);
if (snap === ALL_RIGHT) toggleUnread(mail);
setSnap(mail, snap === ALL_RIGHT ? CLOSED : snap);
};
</script>
<template>
<ul class="inbox lb">
<li v-for="mail in mails" :key="mail.id" :ref="(el) => el && rows.set(mail.id, el as HTMLElement)" class="row">
<div class="row__lead" aria-hidden="true">
<button type="button" class="unread" tabindex="-1" @click="toggleUnread(mail); setSnap(mail, CLOSED)">
Unread
</button>
</div>
<div class="row__trail" aria-hidden="true">
<button type="button" class="flag" tabindex="-1" @click="setSnap(mail, CLOSED)">Flag</button>
<button type="button" class="delete" tabindex="-1" @click="setSnap(mail, ALL_LEFT); remove(mail)">
Delete
</button>
</div>
<!-- Drags don't fire clicks, so a click is a tap -->
<Drag.Root
class="row__face"
axis="x"
:snaps="({ width }) => [width, LEAD, 0, -TRAIL, -width]"
:snap="snapOf(mail)"
:deceleration="0.99"
@start="start(mail)"
@move="({ x }) => follow(mail, x)"
@update:snap="(snap) => land(mail, snap)"
@click="setSnap(mail, CLOSED)"
>
…
</Drag.Root>
</li>
</ul>
</template><script lang="ts">
import { Drag } from "@fluidkit/drag/svelte";
type Mail = { id: string; subject: string };
let { mails }: { mails: Mail[] } = $props();
const LEAD = 92;
const TRAIL = 168;
// Snap points: fully right, left action, closed, right actions, fully left
const ALL_RIGHT = 0;
const CLOSED = 2;
const ALL_LEFT = 4;
// remove(mail) and toggleUnread(mail) are your own functions
// Which row is open, and how far. Only one at a time
let open = $state<{ id: string; snap: number } | null>(null);
const rows = new Map<string, HTMLElement>();
const snapOf = (mail: Mail) => (open?.id === mail.id ? open.snap : CLOSED);
const setSnap = (mail: Mail, snap: number) => (open = snap === CLOSED ? null : { id: mail.id, snap });
// Starting to swipe another row closes this one
const start = (mail: Mail) => open?.id !== mail.id && (open = null);
// Width for the actions; past half way to the edge, the outer action fills the row
const follow = (mail: Mail, x: number) => {
const row = rows.get(mail.id)!;
row.style.setProperty("--x", String(x));
row.toggleAttribute("data-full", x < -(TRAIL + row.clientWidth) / 2 || x > (LEAD + row.clientWidth) / 2);
};
// A full swipe runs the action on release; after marking, slide back closed
const land = (mail: Mail, snap: number) => {
if (snap === ALL_LEFT) remove(mail);
if (snap === ALL_RIGHT) toggleUnread(mail);
setSnap(mail, snap === ALL_RIGHT ? CLOSED : snap);
};
// Each row's element, by its mail
const row = (mail: Mail) => (el: Element) => {
rows.set(mail.id, el as HTMLElement);
return () => rows.delete(mail.id);
};
</script>
<ul class="inbox lb">
{#each mails as mail (mail.id)}
<li {@attach row(mail)} class="row">
<div class="row__lead" aria-hidden="true">
<button
type="button"
class="unread"
tabindex={-1}
onclick={() => {
toggleUnread(mail);
setSnap(mail, CLOSED);
}}>Unread</button
>
</div>
<div class="row__trail" aria-hidden="true">
<button type="button" class="flag" tabindex={-1} onclick={() => setSnap(mail, CLOSED)}>Flag</button>
<button
type="button"
class="delete"
tabindex={-1}
onclick={() => {
setSnap(mail, ALL_LEFT);
remove(mail);
}}>Delete</button
>
</div>
<!-- Drags don't fire clicks, so a click is a tap -->
<Drag.Root
class="row__face"
axis="x"
snaps={({ width }) => [width, LEAD, 0, -TRAIL, -width]}
bind:snap={() => snapOf(mail), (snap) => land(mail, snap)}
deceleration={0.99}
onStart={() => start(mail)}
onMove={({ x }) => follow(mail, x)}
onclick={() => setSnap(mail, CLOSED)}
>
…
</Drag.Root>
</li>
{/each}
</ul>/* The face slides under the row's edge; the separator belongs to the row, so it stays */
.row { position: relative; overflow: clip; }
.row:not([hidden]) ~ .row::before { content: ""; position: absolute; top: 0; left: 16px; right: 16px; border-top: 1px solid rgb(127 127 127 / 0.18); }
.row__face { display: flex; gap: 12px; margin: 0 16px; padding: 12px 0; cursor: grab; user-select: none; }
/* As wide as the space the face uncovers */
.row__lead, .row__trail { position: absolute; top: 6px; bottom: 6px; display: flex; gap: 6px; overflow: hidden; }
.row__lead { left: 10px; width: max(0px, calc(var(--x, 0) * 1px - 16px)); }
.row__trail { right: 10px; width: max(0px, calc(var(--x, 0) * -1px - 16px)); }
.row button { flex: 1 0 0; min-width: 0; border-radius: 999px; color: #fff; transition: flex-grow 0.25s; }
/* Full swipe: Delete fills the row */
.row[data-full] .flag { flex-grow: 0; opacity: 0; }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
<button type="button" class="switch" role="switch" aria-checked="true" aria-label="Wi-Fi">
<span class="switch__thumb lb"></span>
</button>import { createDrag } from "@fluidkit/drag";
const toggle = document.querySelector<HTMLElement>(".switch")!;
const thumb = toggle.querySelector<HTMLElement>(".switch__thumb")!;
const travel = () => toggle.clientWidth - thumb.offsetWidth - 4;
const set = (on: boolean) => toggle.setAttribute("aria-checked", String(on));
const drag = createDrag({
element: thumb,
handle: toggle,
axis: "x",
snaps: () => [0, travel()],
initial: toggle.getAttribute("aria-checked") === "true" ? 1 : 0,
rubberband: 0,
spring: { duration: 0.3, bounce: 0 },
// Trails the pointer slightly, so quick back-and-forth looks smooth
follow: { duration: 0.18, bounce: 0 },
onMove: ({ x }) => toggle.style.setProperty("--on", String(x / travel())),
onEnd: (snap) => set(snap === 1),
});
// Tap or Space toggles. Drags don't fire clicks
toggle.addEventListener("click", () => {
const on = toggle.getAttribute("aria-checked") !== "true";
set(on);
drag.snap(on ? 1 : 0);
});import { useRef } from "react";
import { useMergedRefs } from "@fluidkit/core/react";
import { useDrag } from "@fluidkit/drag/react";
type SwitchProps = { checked: boolean; onCheckedChange: (checked: boolean) => void; label: string };
export function Switch({ checked, onCheckedChange, label }: SwitchProps) {
const toggle = useRef<HTMLButtonElement>(null);
// The handle (the switch) contains the moving thumb, so use the hook
const drag = useDrag({
axis: "x",
snaps: ({ width, element }) => [0, width - element.offsetWidth - 4],
snap: checked ? 1 : 0,
onSnapChange: (snap) => onCheckedChange(snap === 1),
rubberband: 0,
spring: { duration: 0.3, bounce: 0 },
// Trails the pointer slightly, so quick back-and-forth looks smooth
follow: { duration: 0.18, bounce: 0 },
onMove: ({ x }) => {
const el = toggle.current!;
const travel = el.clientWidth - el.firstElementChild!.clientWidth - 4;
el.style.setProperty("--on", String(x / travel));
},
});
return (
<button
ref={useMergedRefs(toggle, drag.handle)}
type="button"
className="switch"
role="switch"
aria-checked={checked}
aria-label={label}
// Tap or Space toggles. Drags don't fire clicks
onClick={() => onCheckedChange(!checked)}
>
<span ref={drag.element} className="switch__thumb lb" />
</button>
);
}<script setup lang="ts">
import { shallowRef } from "vue";
import { useDrag } from "@fluidkit/drag/vue";
defineProps<{ label: string }>();
const checked = defineModel<boolean>({ required: true });
const toggle = shallowRef<HTMLElement | null>(null);
// The handle (the switch) contains the moving thumb, so use the composable
const drag = useDrag(() => ({
axis: "x",
snaps: ({ width, element }) => [0, width - element.offsetWidth - 4],
snap: checked.value ? 1 : 0,
onSnapChange: (snap) => (checked.value = snap === 1),
rubberband: 0,
spring: { duration: 0.3, bounce: 0 },
// Trails the pointer slightly, so quick back-and-forth looks smooth
follow: { duration: 0.18, bounce: 0 },
onMove: ({ x }) => {
const el = toggle.value!;
const travel = el.clientWidth - el.firstElementChild!.clientWidth - 4;
el.style.setProperty("--on", String(x / travel));
},
}));
// One ref for both: the switch element, and the drag's handle
const toggleRef = (el: unknown) => {
toggle.value = el as HTMLElement | null;
drag.handle(el);
};
</script>
<template>
<!-- Tap or Space toggles. Drags don't fire clicks -->
<button
:ref="toggleRef"
type="button"
class="switch"
role="switch"
:aria-checked="checked"
:aria-label="label"
@click="checked = !checked"
>
<span :ref="drag.element" class="switch__thumb lb" />
</button>
</template><script lang="ts">
import { useDrag } from "@fluidkit/drag/svelte";
let { label, checked = $bindable(false) }: { label: string; checked?: boolean } = $props();
let toggle = $state<HTMLElement>();
// The handle (the switch) contains the moving thumb, so use the function
const drag = useDrag(() => ({
axis: "x",
snaps: ({ width, element }) => [0, width - element.offsetWidth - 4],
snap: checked ? 1 : 0,
onSnapChange: (snap) => (checked = snap === 1),
rubberband: 0,
spring: { duration: 0.3, bounce: 0 },
// Trails the pointer slightly, so quick back-and-forth looks smooth
follow: { duration: 0.18, bounce: 0 },
onMove: ({ x }) => {
const el = toggle!;
const travel = el.clientWidth - el.firstElementChild!.clientWidth - 4;
el.style.setProperty("--on", String(x / travel));
},
}));
</script>
<!-- Tap or Space toggles. Drags don't fire clicks -->
<button
bind:this={toggle}
{@attach drag.handle}
type="button"
class="switch"
role="switch"
aria-checked={checked}
aria-label={label}
onclick={() => (checked = !checked)}
>
<span {@attach drag.element} class="switch__thumb lb"></span>
</button>.switch {
position: relative;
width: 64px;
height: 30px;
border-radius: 999px;
background: color-mix(in srgb, #34c759 calc(var(--on, 0) * 100%), rgb(120 120 128 / 0.32));
user-select: none;
}
/* Solid white glass at rest */
.switch__thumb {
--lb-transparency: 0;
--lb-fill: #fff;
position: absolute;
top: 2px;
left: 2px;
width: 38px;
height: 26px;
transition: scale 0.35s, --lb-lens 0.3s, --lb-transparency 0.3s;
}
/* Held: a clear lens over the track. Drag doesn't touch `scale` */
.switch:active .switch__thumb,
.switch__thumb[data-dragging] { --lb-lens: 1; --lb-transparency: 1; scale: 1.45 1.5; }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.
<div class="slider" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60">
<span class="slider__track"><span class="slider__fill"></span></span>
<span class="slider__knob lb lb-stretch"></span>
</div>import { createDrag } from "@fluidkit/drag";
const slider = document.querySelector<HTMLElement>(".slider")!;
const knob = slider.querySelector<HTMLElement>(".slider__knob")!;
const travel = () => slider.clientWidth - knob.offsetWidth;
const drag = createDrag({
element: knob,
handle: slider,
axis: "x",
bounds: () => ({ x: [0, travel()] }),
rubberband: 0.2,
deceleration: 0,
spring: { duration: 0.25, bounce: 0 },
follow: { duration: 0.12, bounce: 0 },
onMove: ({ x }) => {
const v = Math.min(1, Math.max(0, x / travel()));
slider.style.setProperty("--v", String(v));
slider.setAttribute("aria-valuenow", String(Math.round(v * 100)));
},
});
drag.to(travel() * 0.6, { instant: true });
// Tap the track to jump there. Drags don't fire clicks
slider.addEventListener("click", (e) => {
drag.to(e.clientX - slider.getBoundingClientRect().left - knob.offsetWidth / 2);
});
// Arrow keys: drag.to(drag.position.x ± step)import { useEffect, useRef, useState } from "react";
import { useMergedRefs } from "@fluidkit/core/react";
import { useDrag } from "@fluidkit/drag/react";
export function Volume() {
const slider = useRef<HTMLDivElement>(null);
const knob = useRef<HTMLSpanElement>(null);
const [value, setValue] = useState(0.6);
const travel = () => slider.current!.clientWidth - knob.current!.offsetWidth;
const drag = useDrag({
axis: "x",
bounds: ({ width, element }) => ({ x: [0, width - element.offsetWidth] }),
rubberband: 0.2,
deceleration: 0,
spring: { duration: 0.25, bounce: 0 },
follow: { duration: 0.12, bounce: 0 },
onMove: ({ x }) => setValue(Math.min(1, Math.max(0, x / travel()))),
});
// Initial position
useEffect(() => drag.to(travel() * 0.6, { instant: true }), [drag.drag]);
return (
<div
ref={useMergedRefs(slider, drag.handle)}
className="slider"
role="slider"
tabIndex={0}
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
style={{ "--v": value } as React.CSSProperties}
// Tap the track to jump there. Drags don't fire clicks
onClick={(e) => drag.to(e.clientX - slider.current!.getBoundingClientRect().left - knob.current!.offsetWidth / 2)}
// Arrow keys: drag.to(drag.drag.position.x ± step)
>
<span className="slider__track">
<span className="slider__fill" />
</span>
<span ref={useMergedRefs(knob, drag.element)} className="slider__knob lb lb-stretch" />
</div>
);
}<script setup lang="ts">
import { ref, shallowRef, watch } from "vue";
import { useDrag } from "@fluidkit/drag/vue";
const slider = shallowRef<HTMLElement | null>(null);
const knob = shallowRef<HTMLElement | null>(null);
const value = ref(0.6);
const travel = () => slider.value!.clientWidth - knob.value!.offsetWidth;
const drag = useDrag({
axis: "x",
bounds: ({ width, element }) => ({ x: [0, width - element.offsetWidth] }),
rubberband: 0.2,
deceleration: 0,
spring: { duration: 0.25, bounce: 0 },
follow: { duration: 0.12, bounce: 0 },
onMove: ({ x }) => (value.value = Math.min(1, Math.max(0, x / travel()))),
});
// One ref for both: each element, and the drag's
const sliderRef = (el: unknown) => {
slider.value = el as HTMLElement | null;
drag.handle(el);
};
const knobRef = (el: unknown) => {
knob.value = el as HTMLElement | null;
drag.element(el);
};
// Initial position
watch(drag.drag, (engine) => engine?.to(travel() * 0.6, { instant: true }));
// Tap the track to jump there. Drags don't fire clicks
const jump = (e: MouseEvent) =>
drag.to(e.clientX - slider.value!.getBoundingClientRect().left - knob.value!.offsetWidth / 2);
// Arrow keys: drag.to(drag.drag.value.position.x ± step)
</script>
<template>
<div
:ref="sliderRef"
class="slider"
role="slider"
tabindex="0"
aria-label="Volume"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="Math.round(value * 100)"
:style="{ '--v': value }"
@click="jump"
>
<span class="slider__track"><span class="slider__fill" /></span>
<span :ref="knobRef" class="slider__knob lb lb-stretch" />
</div>
</template><script lang="ts">
import { useDrag } from "@fluidkit/drag/svelte";
let slider = $state<HTMLElement>();
let knob = $state<HTMLElement>();
let value = $state(0.6);
const travel = () => slider!.clientWidth - knob!.offsetWidth;
const drag = useDrag(() => ({
axis: "x",
bounds: ({ width, element }) => ({ x: [0, width - element.offsetWidth] }),
rubberband: 0.2,
deceleration: 0,
spring: { duration: 0.25, bounce: 0 },
follow: { duration: 0.12, bounce: 0 },
onMove: ({ x }) => (value = Math.min(1, Math.max(0, x / travel()))),
}));
// Initial position, once the drag is made
$effect(() => {
drag.drag?.to(travel() * 0.6, { instant: true });
});
// Tap the track to jump there. Drags don't fire clicks
const jump = (e: MouseEvent) =>
drag.to(e.clientX - slider!.getBoundingClientRect().left - knob!.offsetWidth / 2);
// Arrow keys step by a tenth
const step = (e: KeyboardEvent) => {
const by = e.key === "ArrowRight" ? 0.1 : e.key === "ArrowLeft" ? -0.1 : 0;
if (by) drag.to(travel() * Math.min(1, Math.max(0, value + by)));
};
</script>
<div
bind:this={slider}
{@attach drag.handle}
class="slider"
role="slider"
tabindex={0}
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
style:--v={value}
onclick={jump}
onkeydown={step}
>
<span class="slider__track"><span class="slider__fill"></span></span>
<span bind:this={knob} {@attach drag.element} class="slider__knob lb lb-stretch"></span>
</div>.slider { --knob: 34px; position: relative; height: 28px; user-select: none; }
.slider__track { position: absolute; top: 50%; left: 0; right: 0; height: 6px; translate: 0 -50%; overflow: clip; border-radius: 999px; background: rgb(120 120 128 / 0.32); }
.slider__fill { display: block; height: 100%; width: calc(var(--v, 0) * 100%); background: #007aff; }
/* White glass at rest; while held, a clear lens that stretches only along the track */
.slider__knob {
--lb-transparency: 0;
--lb-fill: #fff;
--lb-stretch: 0.5;
--lb-stretch-axis: x;
position: absolute;
top: 3px;
left: 0;
width: var(--knob);
height: 22px;
transition: scale 0.35s, --lb-lens 0.3s, --lb-transparency 0.3s;
}
.slider:active .slider__knob,
.slider__knob[data-dragging] { --lb-lens: 1; --lb-transparency: 1; scale: 1.4 1.45; }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.
import { createDrag } from "@fluidkit/drag";
const room = document.querySelector<HTMLElement>(".room")!;
const video = room.querySelector<HTMLElement>(".pip")!;
const drag = createDrag({
element: video,
// Laid out in the top-left corner, 16px in. Corners are recomputed on resize
snaps: () => {
const x = room.clientWidth - video.offsetWidth - 32;
const y = room.clientHeight - video.offsetHeight - 32;
return [{ x: 0, y: 0 }, { x, y: 0 }, { x: 0, y }, { x, y }];
},
initial: 3,
spring: { duration: 0.45, bounce: 0.2 },
});
// Double-tap toggles the size
let lastTap = 0;
video.addEventListener("click", (e) => {
if (e.timeStamp - lastTap > 320) return void (lastTap = e.timeStamp);
lastTap = 0;
video.toggleAttribute("data-big");
});
// Pinch: set drag.disabled = true while resizing with two fingersimport { useRef, useState } from "react";
import { Drag } from "@fluidkit/drag/react";
export function Pip({ src }: { src: string }) {
const [big, setBig] = useState(false);
const lastTap = useRef(0);
return (
<div className="room">
<Drag.Root
// Laid out in the top-left corner, 16px in. Corners are recomputed on resize
snaps={({ width, height, element }) => {
const x = width - element.offsetWidth - 32;
const y = height - element.offsetHeight - 32;
return [{ x: 0, y: 0 }, { x, y: 0 }, { x: 0, y }, { x, y }];
}}
defaultSnap={3}
spring={{ duration: 0.45, bounce: 0.2 }}
className="pip"
data-big={big || undefined}
// Double-tap toggles the size
onClick={(e) => {
if (e.timeStamp - lastTap.current > 320) return void (lastTap.current = e.timeStamp);
lastTap.current = 0;
setBig((b) => !b);
}}
render={<video src={src} muted autoPlay loop playsInline />}
/>
</div>
);
}
// Pinch: pass `disabled` while resizing with two fingers<script setup lang="ts">
import { ref } from "vue";
import { Drag, type DragArea } from "@fluidkit/drag/vue";
defineProps<{ src: string }>();
const big = ref(false);
// Laid out in the top-left corner, 16px in. Corners are recomputed on resize
const corners = ({ width, height, element }: DragArea) => {
const x = width - element.offsetWidth - 32;
const y = height - element.offsetHeight - 32;
return [{ x: 0, y: 0 }, { x, y: 0 }, { x: 0, y }, { x, y }];
};
// Double-tap toggles the size
let lastTap = 0;
const tap = (e: MouseEvent) => {
if (e.timeStamp - lastTap > 320) return void (lastTap = e.timeStamp);
lastTap = 0;
big.value = !big.value;
};
// Pinch: pass `disabled` while resizing with two fingers
</script>
<template>
<div class="room">
<Drag.Root :snaps="corners" :default-snap="3" :spring="{ duration: 0.45, bounce: 0.2 }" as-child>
<video class="pip" :data-big="big || undefined" :src="src" muted autoplay loop playsinline @click="tap" />
</Drag.Root>
</div>
</template><script lang="ts">
import { Drag, type DragArea } from "@fluidkit/drag/svelte";
let { src }: { src: string } = $props();
let big = $state(false);
// Laid out in the top-left corner, 16px in. Corners are recomputed on resize
const corners = ({ width, height, element }: DragArea) => {
const x = width - element.offsetWidth - 32;
const y = height - element.offsetHeight - 32;
return [{ x: 0, y: 0 }, { x, y: 0 }, { x: 0, y }, { x, y }];
};
// Double-tap toggles the size
let lastTap = 0;
const tap = (e: MouseEvent) => {
if (e.timeStamp - lastTap > 320) return void (lastTap = e.timeStamp);
lastTap = 0;
big = !big;
};
// Pinch: pass `disabled` while resizing with two fingers
</script>
<div class="room">
<Drag.Root snaps={corners} defaultSnap={3} spring={{ duration: 0.45, bounce: 0.2 }}>
{#snippet child({ props })}
<video {...props} class="pip" data-big={big || undefined} {src} muted autoplay loop playsinline onclick={tap}
></video>
{/snippet}
</Drag.Root>
</div>.room { position: relative; height: 380px; overflow: clip; }
.pip {
position: absolute;
top: 16px;
left: 16px;
width: 160px;
aspect-ratio: 16 / 10;
border-radius: 16px;
cursor: grab;
transition: width 0.4s, scale 0.25s;
}
.pip[data-big] { width: 264px; }
/* Slightly larger while dragged. Drag doesn't touch `scale` */
.pip[data-dragging] { cursor: grabbing; scale: 1.04; }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
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.
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.
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:
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.
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.