svelte-drawer-sheet
v0.1.5
Published
A fast, accessible, unstyled drawer/sheet primitive for Svelte 5.
Maintainers
Readme
svelte-drawer-sheet
svelte-drawer-sheet is an unstyled and accessible drawer for Svelte 5. It supports gestures, snap
points, nested drawers, detached triggers, custom elements, and an optional virtual keyboard.
Base UI's Drawer inspired this library. The
svelte-drawer-sheet project has no maintenance connection to Base UI. The library does not provide a
compatibility layer. See the
third-party notices.
Install
# pnpm
pnpm add svelte-drawer-sheet
# npm
npm install svelte-drawer-sheet
# Yarn
yarn add svelte-drawer-sheetUse Svelte version 5.40.0 or a later Svelte 5 release. Use a bundler that supports Svelte.
Basic drawer
<script lang="ts">
import * as Drawer from 'svelte-drawer-sheet';
let open = $state(false);
</script>
<Drawer.Root bind:open>
<Drawer.Trigger class="drawer-trigger">Open drawer</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Backdrop class="drawer-backdrop" />
<Drawer.Viewport class="drawer-viewport">
<Drawer.Popup class="drawer-popup">
<Drawer.Title>Account settings</Drawer.Title>
<Drawer.Description>Update the details associated with your account.</Drawer.Description>
<Drawer.Content class="drawer-content">
<!-- Form or other scrollable content -->
</Drawer.Content>
<Drawer.Close>Done</Drawer.Close>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>The library does not supply visual CSS. Use this CSS for a bottom drawer. This example uses the
default value swipeDirection="down".
.drawer-backdrop {
--backdrop-opacity: 0.2;
position: fixed;
inset: 0;
min-height: 100dvh;
background: black;
opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress)));
transition: opacity 450ms cubic-bezier(0.32, 0.72, 0, 1);
}
.drawer-backdrop[data-starting-style],
.drawer-backdrop[data-ending-style] {
opacity: 0;
}
.drawer-viewport {
position: fixed;
inset: 0;
display: flex;
align-items: flex-end;
pointer-events: none;
}
.drawer-viewport[hidden] {
display: none;
}
.drawer-popup {
--bleed: 3rem;
box-sizing: border-box;
width: 100%;
max-height: calc(80dvh + var(--bleed));
margin-bottom: calc(-1 * var(--bleed));
padding: 1rem 1.5rem;
padding-bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px) + var(--bleed));
border-radius: 1rem 1rem 0 0;
background: white;
box-shadow: 0 -1rem 3rem rgb(0 0 0 / 0.15);
overflow: auto;
overscroll-behavior: contain;
pointer-events: auto;
transform: translateY(calc(var(--drawer-snap-point-offset) + var(--drawer-swipe-movement-y)));
transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1);
}
.drawer-popup[data-starting-style],
.drawer-popup[data-ending-style] {
transform: translateY(calc(100% - var(--bleed) + 2px));
}
.drawer-popup[data-ending-style],
.drawer-backdrop[data-ending-style] {
transition-duration: calc(var(--drawer-swipe-strength) * 400ms);
}
.drawer-popup[data-swiping],
.drawer-backdrop[data-swiping] {
transition-duration: 0ms;
user-select: none;
}
.drawer-content {
overflow: auto;
overscroll-behavior: contain;
}
@media (prefers-reduced-motion: reduce) {
.drawer-popup,
.drawer-backdrop {
/* Also override the swipe-release duration. */
transition-duration: 0ms !important;
}
}--bleed extends the drawer surface past the screen edge. This extension hides the page background
during overshoot. For another direction, change the edge, safe-area inset, movement variable, and
exit sign. Align Viewport with the same edge.
State and change events
You can bind open, snapPoint, triggerId, and actions. Use the related default* prop to set
the initial uncontrolled state.
<script lang="ts">
import * as Drawer from 'svelte-drawer-sheet';
import type { DrawerChangeEventDetails, DrawerRootActions } from 'svelte-drawer-sheet';
let open = $state(false);
let actions = $state<DrawerRootActions>();
let hasUnsavedChanges = $state(false);
function handleOpenChange(nextOpen: boolean, details: DrawerChangeEventDetails) {
if (!nextOpen && hasUnsavedChanges) details.cancel();
}
</script>
<button onclick={() => actions?.open()}>Open imperatively</button>
<Drawer.Root bind:open bind:actions onOpenChange={handleOpenChange}>
<!-- drawer parts -->
</Drawer.Root>Each change details object contains event, trigger, and reason. The reason has one of these
values:
trigger-press, outside-press, escape-key, close-watcher, close-press, focus-out,
imperative-action, swipe, or none.
- Call
details.cancel()to reject the change. - Call
details.preventUnmountOnClose()to delay the unmount operation. Then, callactions.unmount()after the external transition. RootcallsonOpenChangeCompleteafter the transition.
Modal options
The default value of modal is true.
| modal value | Focus trap | Page scroll lock | Close after outside focus |
| -------------- | ---------- | ---------------- | ------------------------- |
| true | Yes | Yes | No |
| 'trap-focus' | Yes | No | No |
| false | No | No | Yes |
disablePointerDismissal prevents only pointer dismissal and focus-out dismissal.
Focus behavior
Popup adds role="dialog". It links Title and Description to the dialog. Add both parts to give
the dialog an accessible name and description.
By default, Popup gets focus when the drawer opens. This action prevents an unexpected mobile
keyboard. Set initialFocus and finalFocus to one of these values:
true: Focus the first tabbable element. If no such element exists, focus the popup.falseornull: Do not move focus automatically.- An
HTMLElement: Focus that element. - A callback: Use the interaction type to select the focus action. Return
nullfor the default action. Returnfalseorundefinedto prevent focus movement.
An external initialFocus target stays in the modal boundary. When the drawer closes, the library
usually returns focus to the opening trigger. This behavior also applies to detached triggers.
onOpenAutoFocus, onCloseAutoFocus, onEscapeKeydown, and onInteractOutside get cancelable
browser events. onFocusOutside gets a cancelable event wrapper. Call event.preventDefault() to
prevent the default action.
Use getInsideElements for a portaled menu, combobox, or similar control. The specified elements
stay in the interaction and accessibility boundary.
<Drawer.Popup getInsideElements={() => [menuPopup]}>
<!-- drawer content -->
</Drawer.Popup>Snap points
Use snap points only with vertical drawers. The library accepts these values:
- A number from
0through1. The number specifies a fraction of the viewport height. - A number greater than
1. The number specifies pixels. - A pixel or root-relative CSS length, such as
'320px'or'24rem'.
The library limits each value to the viewport height and popup height.
<script lang="ts">
import * as Drawer from 'svelte-drawer-sheet';
import type { DrawerSnapPoint } from 'svelte-drawer-sheet';
const snapPoints = ['16rem', 0.65, 1] as const satisfies readonly DrawerSnapPoint[];
let snapPoint = $state<DrawerSnapPoint | null>('16rem');
</script>
<Drawer.Root
{snapPoints}
defaultSnapPoint="16rem"
bind:snapPoint
snapToSequentialPoints
onSnapPointChange={(nextPoint, details) => {
// Call details.cancel() to reject this change.
}}
>
<!-- drawer parts -->
</Drawer.Root>Set snapToSequentialPoints to move through the snap points in sequence. Add 1 for a fully
expanded state. This state adds data-expanded to Popup.
Detached triggers and payloads
Use createHandle to connect external triggers to Root. The Root children snippet gets each
typed payload.
<script lang="ts">
import * as Drawer from 'svelte-drawer-sheet';
type Item = { id: string; label: string };
const drawer = Drawer.createHandle<Item>();
</script>
<Drawer.Trigger handle={drawer} payload={{ id: 'one', label: 'First item' }}>
Edit first item
</Drawer.Trigger>
<Drawer.Root handle={drawer}>
{#snippet children({ payload })}
<Drawer.Portal>
<Drawer.Backdrop class="drawer-backdrop" />
<Drawer.Viewport class="drawer-viewport">
<Drawer.Popup class="drawer-popup">
<Drawer.Title>Edit {payload?.label ?? 'item'}</Drawer.Title>
<!-- drawer content -->
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
{/snippet}
</Drawer.Root>The handle also supplies imperative methods:
drawer.open();
drawer.open('trigger-element-id');
drawer.openWithPayload({ id: 'two', label: 'Second item' });
drawer.close();Use open(triggerId) to identify the trigger for focus restoration. Use handle methods only in the
browser.
Custom elements
Each part can accept a child snippet. The snippet gets merged props and reactive state. Put all
supplied props on exactly one element. This action keeps the required behavior and accessibility.
{#snippet customTrigger({ props, state })}
<div {...props} class:open={state.open}>Open with a custom element</div>
{/snippet}
<Drawer.Trigger nativeButton={false} child={customTrigger} />Trigger and Close render buttons by default. If a child does not render a button, set
nativeButton={false}. The component adds the button semantics. You can bind ref on most rendered
parts.
Swipe areas and gesture exclusions
SwipeArea opens a closed drawer from the edge opposite the dismissal direction.
<Drawer.Root>
<Drawer.SwipeArea class="bottom-edge-swipe-area" />
<!-- portal, viewport, and popup -->
</Drawer.Root>Add data-drawer-swipe-ignore to a region that must keep native gestures. Content identifies the
scroll region. A drawer gesture starts only when the content reaches its scroll boundary.
Navigation swipe behavior
Use swipeBehavior="navigation" for a full-screen horizontal view that represents the next level
in a navigation hierarchy.
<Drawer.Root swipeDirection="right" swipeBehavior="navigation">
<!-- trigger, full-screen popup, and other parts -->
</Drawer.Root>Navigation behavior can start anywhere on the popup. It projects recent release velocity to resolve
intent, respects a reversal at release, scales the settle duration to the distance left, and emits
a velocity-matched settle curve. It applies only to horizontal drawers. Vertical drawers keep the
default drawer behavior, including when navigation is supplied. The default behavior is
unchanged.
Use a 380ms base transition with the emitted strength and easing properties. This preserves motion continuity when the finger releases.
.navigation-popup[data-swipe-behavior='navigation'] {
transition: transform calc(380ms * var(--drawer-swipe-strength, 1))
var(--drawer-swipe-easing, cubic-bezier(0.32, 0.72, 0, 1));
}
.navigation-popup[data-swiping] {
transition: none;
}For a page-stack transition, wrap each page's Popup in a Provider and Indent. A nested
navigation root inside that popup drives the underlay. Use the same timing properties on both
layers. Indent receives dismissal progress and settle state without changing presentation on its
own.
Give each full-screen page its own Provider and Indent around that page's Popup. A nested
navigation root then drives only the page immediately beneath it. Repeat the same structure at every
level for an arbitrarily deep stack. Scope the page-stack CSS to
[data-swipe-behavior='navigation']; bottom drawers keep their normal behavior even inside the same
provider scope.
<Drawer.Root swipeDirection="right" swipeBehavior="navigation">
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Provider>
<Drawer.Indent class="navigation-underlay">
<Drawer.Popup class="navigation-popup">
<!-- A nested navigation Root here drives this Indent. -->
</Drawer.Popup>
</Drawer.Indent>
</Drawer.Provider>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>.navigation-underlay[data-active][data-swipe-behavior='navigation'] {
transform: translateX(calc(-22vw * (1 - var(--drawer-swipe-progress, 0))));
transition: transform calc(380ms * var(--drawer-swipe-strength, 1))
var(--drawer-swipe-easing, cubic-bezier(0.32, 0.72, 0, 1));
}
.navigation-underlay[data-swiping] {
transition: none;
}
.drawer-viewport {
z-index: calc(100 + var(--drawer-nesting-depth, 0));
}Viewport sets --drawer-nesting-depth to 0 for a top-level root and increments it for every
nested root, so dynamic stacks do not need per-level z-index classes.
Browser history with SvelteKit
Keep routing outside the drawer by binding open to typed shallow state. Push an entry when the
page opens and navigate back when a user gesture or control closes it.
<script lang="ts">
import { pushState } from '$app/navigation';
import { page } from '$app/state';
let open = $derived(page.state.drawerOpen === true);
</script>
<Drawer.Root
bind:open
onOpenChange={(nextOpen) =>
nextOpen ? pushState('', { ...page.state, drawerOpen: true }) : history.back()}
>
<!-- parts -->
</Drawer.Root>Declare drawerOpen in App.PageState. For nested pages, store the active page key and push one
entry per level so browser Back and Forward unwind and restore the stack.
Portals and presence
By default, Portal moves content to document.body. The to prop accepts a selector, a connected
element, or a connected open shadow root. The target must be in the same document. If a selector
does not find a target, Portal uses body. Portal does not accept closed shadow roots or targets
in other documents. Set disabled to keep content in its initial location.
Set keepMounted to keep closed content in the DOM. Closed content stays hidden or inert. By
default, a nested drawer does not show its Backdrop. Set Backdrop.forceRender to show it.
Use only one Viewport and one Popup in each Root. Use child snippets for responsive element
changes.
Nested drawers and page indentation
A nested Root shares its stack measurements and state with the parent Root. Svelte context keeps
this connection through portals.
The library supplies the measurements. Use CSS to control the scale and visible edge.
.drawer-popup--stack {
--stack-progress: clamp(0, var(--drawer-swipe-progress), 1);
--stack-step: 0.05;
--stack-peek: max(0px, calc((var(--nested-drawers) - var(--stack-progress)) * 1rem));
--stack-scale-base: max(0, calc(1 - var(--nested-drawers) * var(--stack-step)));
--stack-scale: calc(var(--stack-scale-base) + var(--stack-step) * var(--stack-progress));
--stack-height: max(
0px,
calc(var(--drawer-frontmost-height, var(--drawer-height)) - var(--bleed))
);
--stack-y: calc(
var(--drawer-swipe-movement-y) - var(--stack-peek) -
((1 - var(--stack-scale)) * var(--stack-height))
);
height: var(--drawer-height, auto);
transform: translateY(var(--stack-y)) scale(var(--stack-scale));
transform-origin: 50% calc(100% - var(--bleed));
transition:
transform 450ms cubic-bezier(0.32, 0.72, 0, 1),
height 450ms cubic-bezier(0.32, 0.72, 0, 1);
}
.drawer-popup--stack[data-nested-drawer-open] {
/* Keep the background sheet at the frontmost height. Remove its height limits in the stack. */
height: calc(var(--stack-height) + var(--bleed));
min-height: 0;
max-height: none;
overflow: hidden;
}
.drawer-popup--stack[data-swiping],
.drawer-popup--stack[data-nested-drawer-swiping] {
transition-duration: 0ms;
}Use Provider, Indent, and IndentBackground to send drawer progress to page-level CSS. These
parts do not change the page unless your CSS changes it.
Virtual keyboard
Put VirtualKeyboardProvider around a drawer that contains text inputs.
<Drawer.Root>
<Drawer.VirtualKeyboardProvider>
<Drawer.Portal>
<!-- backdrop, viewport, and popup -->
</Drawer.Portal>
</Drawer.VirtualKeyboardProvider>
</Drawer.Root>If the browser supports visualViewport, the provider keeps the focused control visible. The
provider writes --drawer-keyboard-inset to Viewport. Use this property in the popup or scroll
container.
.drawer-popup {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--drawer-keyboard-inset, 0px));
transition:
transform 440ms cubic-bezier(0.32, 0.72, 0, 1),
padding-bottom 440ms cubic-bezier(0.32, 0.72, 0, 1);
}The provider stops while page zoom is active. On touch devices, set the input font size to 1rem or
more. This font size prevents iOS focus zoom.
@media (any-pointer: coarse) {
.drawer-input {
font-size: 1rem;
}
}Style
These attributes and variables are the stable style API. Do not use other implementation details.
Data attributes
| Part | Attributes |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Trigger | data-popup-open, data-disabled |
| Close | data-disabled |
| Backdrop | data-open, data-closed, data-swiping, data-swipe-dismiss, data-starting-style, data-ending-style |
| Viewport | data-open, data-closed, data-nested, data-nested-drawer-open |
| Popup | data-open, data-closed, data-expanded, data-nested, data-nested-drawer-open, data-nested-drawer-swiping, data-swipe-behavior, data-swipe-direction, data-swipe-dismiss, data-swiping, data-starting-style, data-ending-style |
| Content | data-drawer-content |
| SwipeArea | data-open, data-closed, data-disabled, data-swipe-direction, data-swiping |
| Indent | data-active, data-inactive, data-swipe-behavior, data-swiping |
| IndentBackground | data-active, data-inactive |
CSS custom properties
| Variable | Element | Meaning |
| ---------------------------- | ----------------------------- | -------------------------------------------------------------------------------- |
| --drawer-swipe-movement-x | Popup | Current horizontal drag distance |
| --drawer-swipe-movement-y | Popup | Current vertical drag distance |
| --drawer-swipe-progress | Popup, Backdrop, Indent | Nested drawer progress on Popup. Other elements show snap or dismissal progress. |
| --drawer-swipe-strength | Popup, Backdrop, Indent | Settle duration multiplier from 0.1 through 1 |
| --drawer-swipe-easing | Popup, Backdrop, Indent | Velocity-matched navigation settle curve |
| --drawer-snap-point-offset | Popup | Pixel offset of the active snap point |
| --drawer-height | Popup, Backdrop, Indent | auto at rest. Height in pixels during nesting or exit. |
| --drawer-frontmost-height | Popup | Height of the frontmost nested drawer |
| --nested-drawers | Popup | Number of open nested drawers |
| --drawer-nesting-depth | Viewport | Zero-based root depth for dynamic visual layering |
| --drawer-keyboard-inset | Viewport | Visual viewport inset when a software keyboard is visible |
Popup[data-swipe-direction] gives the root direction to CSS.
Component overview
| Export | Purpose |
| ------------------------------ | --------------------------------------------------------------- |
| Root | Drawer state, behavior, nesting, snap points, and callbacks |
| Trigger | Native or custom trigger with optional handle connection |
| Portal | Optional content relocation and mounted-state control |
| Backdrop | Top-level overlay and swipe-progress surface |
| Viewport | Presence container, measurement limit, and dismissal controller |
| Popup | Accessible dialog surface, focus control, and style state |
| Content | Content and scroll-region identifier |
| Title | Accessible dialog title with a default level of 2 |
| Description | Accessible dialog description |
| Close | Native or custom close control |
| SwipeArea | Edge gesture that opens a closed drawer |
| Provider | Page indentation controller for multiple drawers |
| Indent | Foreground page region with current drawer measurements |
| IndentBackground | Background page region with active and inactive states |
| VirtualKeyboardProvider | Optional visualViewport keyboard controller |
| createHandle, DrawerHandle | Detached trigger, payload, and imperative API |
The package root exports all public prop types and state types.
Server-side rendering
You can import the package during server-side rendering. The package starts DOM operations after the elements mount. Use refs, DOM targets, and imperative methods only in the browser. The package has no runtime component-library dependency.
Development
Use Node 24 and pnpm 12.
pnpm install --frozen-lockfile
pnpm exec playwright install chromium
pnpm validatepnpm package creates dist. pnpm pack --dry-run lists the files in the npm package.
License
The package uses the MIT License. Read LICENSE and THIRD_PARTY_NOTICES.md.
