Drawer
Bottom-anchored pull-up built on vaul-svelte. Drag the handle to dismiss, scale-fades the page behind, springs back when released short of the threshold.
Usage
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import { browser } from '$app/environment';
// `disablePreventScroll` is inversely named upstream — `true` _enables_
// the focus-time touchmove/translateY tricks. Default `true` matches vaul's
// internal default (the docstring is the inverse of the actual behavior).
//
// `repositionInputs={false}` disables vaul's `onVisualViewportChange`,
// which writes inline `style.bottom` and `style.height` on the drawer
// node when an input is focused. Two reasons to skip it:
// 1. Inline styles override our Tailwind `bottom-(--keyboard-inset,0px)`
// and `max-h-[min(80dvh,var(--visible-h,80dvh))]` rules, breaking
// the visualViewport-driven positioning we set in drawer-content.
// 2. Vaul's calc uses `window.innerHeight - vv.height` only — it never
// reads `vv.offsetTop`, so on iOS Safari 26 where the keyboard pans
// the visual viewport instead of (or in addition to) resizing, the
// drawer ends up offset by the pan amount and clips past the top.
// Our drawer-content listener handles both `vv.resize` and `vv.scroll`
// and uses `vv.offsetTop + vv.height` to compute the inset, so it covers
// every iOS keyboard mode without help from vaul.
let {
shouldScaleBackground = true,
disablePreventScroll = true,
repositionInputs = false,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
// Vaul-svelte's body lock (`usePositionFixed`) gates on a `hasBeenOpened`
// flag that only flips when bits-ui writes the open prop back. We drive
// `open` from a parent store and pass it down read-only, so vaul never
// engages its lock. We replicate the lock here.
//
// IMPORTANT: this MUST be a plain `$effect`, not `$effect.pre`. Vaul's
// `handleOpenChange` snapshots `document.body.style.cssText` when the
// drawer opens, then restores that snapshot 500ms after close. If we
// lock the body *before* vaul snapshots, the snapshot captures our
// locked styles — and 500ms after close vaul re-applies them, leaving
// the page un-scrollable until next refresh. With a post-DOM `$effect`,
// vaul snapshots the clean body first, then we lock. On close we
// unlock; vaul's restore reinstates the clean snapshot. The single
// extra microtask delay before lock-on-open is harmless — the user
// can't tap an input within that window.
let savedScrollY = 0;
let savedScrollX = 0;
let locked = false;
function lockBody() {
if (locked) return;
savedScrollY = window.scrollY;
savedScrollX = window.scrollX;
const body = document.body.style;
body.setProperty('position', 'fixed', 'important');
body.top = `-${savedScrollY}px`;
body.left = `-${savedScrollX}px`;
body.right = '0';
body.width = '100%';
body.height = 'auto';
document.documentElement.style.overscrollBehavior = 'none';
locked = true;
}
function unlockBody() {
if (!locked) return;
const body = document.body.style;
body.removeProperty('position');
body.removeProperty('top');
body.removeProperty('left');
body.removeProperty('right');
body.removeProperty('width');
body.removeProperty('height');
document.documentElement.style.removeProperty('overscroll-behavior');
window.scrollTo(savedScrollX, savedScrollY);
locked = false;
}
// iOS Safari auto-scrolls the page to bring a focused input into view, even
// when the input is already visible inside a `position: fixed` drawer.
// Vaul ships this exact workaround inside `preventScrollMobileSafari`, but
// it only runs when `hasBeenOpened` has flipped (see above). We re-apply
// the same `translateY(-2000px)` decoy on focus so iOS skips its scroll.
function isFocusable(target: EventTarget | null): target is HTMLElement {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === 'TEXTAREA') return true;
if (tag === 'INPUT') {
const type = (target as HTMLInputElement).type;
// Non-text input types (button, checkbox, radio, etc.) don't open
// the keyboard, skip them.
return !['button', 'checkbox', 'radio', 'submit', 'reset', 'file', 'hidden'].includes(type);
}
return target.isContentEditable;
}
function onFocusIn(e: FocusEvent) {
const target = e.target;
if (!isFocusable(target)) return;
// Decoy: move the element offscreen so iOS thinks there's nothing to
// scroll, then restore it on the next frame. Visual flicker is
// imperceptible since the same frame paints the restored position.
const prev = target.style.transform;
target.style.transform = 'translateY(-2000px)';
requestAnimationFrame(() => {
target.style.transform = prev;
});
}
$effect(() => {
if (!browser) return;
if (open) {
lockBody();
document.addEventListener('focusin', onFocusIn, true);
} else {
document.removeEventListener('focusin', onFocusIn, true);
unlockBody();
}
return () => {
document.removeEventListener('focusin', onFocusIn, true);
unlockBody();
};
});
</script>
<DrawerPrimitive.Root
{shouldScaleBackground}
{disablePreventScroll}
{repositionInputs}
bind:open
bind:activeSnapPoint
{...restProps}
/>
Responsive dialog
Drawer on mobile, Dialog on desktop — same content, breakpoint-switched via matchMedia. Resize the window to see it swap.
<script lang="ts">
import * as Drawer from '$lib/components/ui/drawer';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
let isDesktop = $state(false);
$effect(() => {
const mql = window.matchMedia('(min-width: 768px)');
isDesktop = mql.matches;
const handler = (e: MediaQueryListEvent) => { isDesktop = e.matches; };
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
</script>
{#if isDesktop}
<Dialog.Root>
<Dialog.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Edit profile</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>Make changes here. Saves on submit.</Dialog.Description>
</Dialog.Header>
<div class="flex flex-col gap-3 py-2">
<Input placeholder="Name" />
<Input placeholder="Username" />
</div>
<Dialog.Footer>
<Button>Save changes</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
{:else}
<Drawer.Root>
<Drawer.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Edit profile</Button>
{/snippet}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Edit profile</Drawer.Title>
<Drawer.Description>Make changes here. Saves on submit.</Drawer.Description>
</Drawer.Header>
<div class="flex flex-col gap-3 px-4 pb-2">
<Input placeholder="Name" />
<Input placeholder="Username" />
</div>
<Drawer.Footer>
<Button>Save changes</Button>
<Drawer.Close>
{#snippet child({ props })}
<Button variant="ghost" {...props}>Cancel</Button>
{/snippet}
</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
{/if}Mobile navigation
A single drawer with internal page routing — content slides horizontally between pages using motion.dev animate(). vaul handles vertical
drag-to-dismiss; a $effect drives the x-axis transition on
page change. Reset to the root page on drawer close.
<script lang="ts">
import { animate } from 'motion';
import { springs, prefersReducedMotion } from '$lib/motion';
import * as Drawer from '$lib/components/ui/drawer';
import { Button } from '$lib/components/ui/button';
import { Icon } from '$lib/components/ui/icon';
type NavPage = 'main' | 'account' | 'profile' | 'notifications';
const pageOrder: NavPage[] = ['main', 'account', 'profile', 'notifications'];
const pageTitles: Record<NavPage, string> = {
main: 'Menu',
account: 'Account',
profile: 'Profile',
notifications: 'Notifications'
};
const pageParent: Partial<Record<NavPage, NavPage>> = {
account: 'main',
profile: 'account',
notifications: 'main'
};
let open = $state(false);
let page = $state<NavPage>('main');
let viewport = $state<HTMLElement | null>(null);
$effect(() => {
if (!viewport) return;
const idx = pageOrder.indexOf(page);
const transition = prefersReducedMotion() ? { duration: 0 } : springs.snappy;
animate(viewport, { x: `-${idx * 100}%` }, transition);
});
function onOpenChange(isOpen: boolean) {
open = isOpen;
if (!isOpen) page = 'main';
}
</script>
<Drawer.Root bind:open onOpenChange={onOpenChange}>
<Drawer.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Open nav</Button>
{/snippet}
</Drawer.Trigger>
<Drawer.Content>
<!-- Header -->
<div class="flex h-12 shrink-0 items-center gap-2 px-4">
{#if page !== 'main'}
<button
onclick={() => { const parent = pageParent[page]; if (parent) page = parent; }}
class="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Go back"
>
<Icon name="arrow-left-line" class="h-4 w-4" />
</button>
{/if}
<span class="flex-1 text-sm font-semibold tracking-tight text-foreground">
{pageTitles[page]}
</span>
<Drawer.Close>
{#snippet child({ props })}
<button
{...props}
class="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close"
>
<Icon name="close-line" class="h-4 w-4" />
</button>
{/snippet}
</Drawer.Close>
</div>
<!-- Sliding viewport -->
<div class="relative flex-1 overflow-hidden">
<div bind:this={viewport} class="flex h-full" style="width: 400%; will-change: transform;">
<!-- Main page -->
<div class="flex h-full w-1/4 shrink-0 flex-col gap-0.5 overflow-y-auto px-2 pb-4">
<button onclick={() => (page = 'account')} class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Account</span>
<Icon name="arrow-right-s-line" class="h-4 w-4 text-muted-foreground" />
</button>
<button onclick={() => (page = 'notifications')} class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Notifications</span>
<Icon name="arrow-right-s-line" class="h-4 w-4 text-muted-foreground" />
</button>
<hr class="my-1 border-border" />
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Help</span>
</button>
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Sign out</span>
</button>
</div>
<!-- Account page -->
<div class="flex h-full w-1/4 shrink-0 flex-col gap-0.5 overflow-y-auto px-2 pb-4">
<button onclick={() => (page = 'profile')} class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Profile</span>
<Icon name="arrow-right-s-line" class="h-4 w-4 text-muted-foreground" />
</button>
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Billing</span>
</button>
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Security</span>
</button>
</div>
<!-- Profile page -->
<div class="flex h-full w-1/4 shrink-0 flex-col gap-2 overflow-y-auto px-4 pb-4">
<p class="text-xs text-muted-foreground">Edit your display name and avatar here.</p>
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Change avatar
</button>
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Edit display name
</button>
</div>
<!-- Notifications page -->
<div class="flex h-full w-1/4 shrink-0 flex-col gap-0.5 overflow-y-auto px-2 pb-4">
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Push notifications</span>
</button>
<button class="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="flex-1">Email digest</span>
</button>
</div>
</div>
</div>
</Drawer.Content>
</Drawer.Root>Snap points
Pass snapPoints as fractions or pixel strings. The drawer
rests at each stop; drag past the last to dismiss.
<script lang="ts">
import * as Drawer from '$lib/components/ui/drawer';
import { Button } from '$lib/components/ui/button';
let snapPoint = $state<string | number>(0.4);
</script>
<Drawer.Root snapPoints={[0.4, 0.8, 1]} bind:activeSnapPoint={snapPoint}>
<Drawer.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Open with snap points</Button>
{/snippet}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Snap points</Drawer.Title>
<Drawer.Description>Drag to 40 %, 80 %, or full height.</Drawer.Description>
</Drawer.Header>
<div class="px-4 pb-4 text-sm text-muted-foreground">
Active snap: {snapPoint}
</div>
</Drawer.Content>
</Drawer.Root>Nested drawers
Use Drawer.NestedRoot inside any Drawer.Content to stack a second drawer on top.
<script lang="ts">
import * as Drawer from '$lib/components/ui/drawer';
import { Button } from '$lib/components/ui/button';
</script>
<Drawer.Root>
<Drawer.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Open outer drawer</Button>
{/snippet}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Outer drawer</Drawer.Title>
<Drawer.Description>Opens a second nested drawer on top.</Drawer.Description>
</Drawer.Header>
<div class="px-4 pb-4">
<Drawer.NestedRoot>
<Drawer.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Open inner drawer</Button>
{/snippet}
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Inner drawer</Drawer.Title>
<Drawer.Description>Nested on top of the outer.</Drawer.Description>
</Drawer.Header>
<Drawer.Footer>
<Drawer.Close>
{#snippet child({ props })}
<Button variant="ghost" {...props}>Close inner</Button>
{/snippet}
</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.NestedRoot>
</div>
<Drawer.Footer>
<Drawer.Close>
{#snippet child({ props })}
<Button variant="ghost" {...props}>Close outer</Button>
{/snippet}
</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>Scrollable content
Wrap the body in a ScrollArea with a height cap — the drag
handle and footer stay anchored, only the inner list scrolls.
<Drawer.Content>
<Drawer.Header>...</Drawer.Header>
<ScrollArea class="max-h-[60vh] px-4">
<!-- long list -->
</ScrollArea>
<Drawer.Footer>...</Drawer.Footer>
</Drawer.Content>Drawer.Root props
Inherits vaul-svelte Drawer.Root props via spread.
| Prop | Type | Default | Description |
|---|---|---|---|
boolean (bindable) | false | Visibility, two-way bindable. | |
boolean | true | Scales the page body back as the drawer rises, for a stacked-card feel. | |
string | number | null (bindable) | null | Currently active snap point when snapPoints are configured. | |
(string | number)[] | — | Optional fractions or px values that the drawer can rest at. |