Drawer
A panel from a screen edge.
Anatomy
<script setup>
import { Drawer } from '@shardsui/vue/drawer'
</script>
<template>
<Drawer.Provider>
<Drawer.IndentBackground />
<Drawer.Indent>
<Drawer.Root>
<Drawer.Trigger />
<Drawer.SwipeArea />
<Drawer.Portal>
<Drawer.Backdrop />
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Content>
<Drawer.Title />
<Drawer.Description />
<Drawer.Close />
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</Drawer.Indent>
</Drawer.Provider>
</template>Swipe gestures dismiss the drawer; swipeDirection sets which direction. <Drawer.Viewport> owns those gestures and the touch scroll locking, so <Drawer.Popup> must render inside it. <Drawer.Content> lets mouse users select text in its children without swipe interference; add data-shards-ui-swipe-ignore to a descendant to opt it out of swipe dismissal for all input types.
In Chromium on Android, the system back gesture closes the topmost open drawer.
Usage guidelines
- Drawer extends Dialog: it adds gesture support, snap points, and indent effects. If you don't need these, a slide-in panel is just a positioned Dialog, so use Dialog instead.
Examples
State
By default, Drawer manages its own state.
<template>
<Drawer.Root>
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Content>
<Drawer.Title>Example drawer</Drawer.Title>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</template>Drive open with open / @update:open, or v-model:open.
<script setup>
import { shallowRef } from 'vue'
const open = shallowRef(false)
</script>
<template>
<Drawer.Root :open="open" @update:open="(next) => (open = next)">
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Content>
<Drawer.Title>Example drawer</Drawer.Title>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</template>Position
Positioning is handled by your styles. swipeDirection defaults to "down" for bottom sheets. Use "up", "left", or "right" for other drawer positions.
<template>
<Drawer.Root swipe-direction="left">...</Drawer.Root>
</template>Nested drawers
Use the [data-nested-drawer-open] selector and the --nested-drawers CSS variable to style drawers when a nested drawer is open. The demo stacks them with a constant peek: the frontmost drawer stays anchored to the bottom while the ones behind are scaled down and lifted.
Snap points
Use snapPoints to snap a bottom sheet to preset heights. Numbers up to 1 are fractions of the viewport height, numbers above 1 are pixel values, and strings support px and rem units ('148px', '30rem'). Snap points only apply when swipeDirection is "up" or "down".
<script setup>
import { shallowRef } from 'vue'
const snapPoints = ['148px', 1]
const snapPoint = shallowRef(snapPoints[0])
</script>
<template>
<Drawer.Root v-model:snap-point="snapPoint" :snap-points="snapPoints">...</Drawer.Root>
</template>Apply the snap point offset in your styles when using vertical drawers:
.drawer-popup {
transform: translateY(calc(var(--drawer-snap-point-offset) + var(--drawer-swipe-movement-y)));
}Fast swipes can skip snap points. Set snapToSequentialPoints to disable velocity-based skipping so drag distance determines the snap target (you can still drag past multiple points).
Indent effect
To scale the background down whenever a drawer opens, wrap your app in <Drawer.Provider> and place <Drawer.IndentBackground> + <Drawer.Indent> at the top of your tree. Both parts carry data-active while any <Drawer.Root> inside the provider is open.
Non-modal
Set :modal="false" to opt out of focus trapping and disablePointerDismissal to keep the drawer open on outside clicks.
Mobile navigation
Build a full-screen mobile navigation sheet from Drawer parts, with flick-to-dismiss.
Swipe to open
Place <Drawer.SwipeArea> along the edge of the viewport to enable swipe-to-open gestures. It is hidden from assistive technology, so keep a <Drawer.Trigger> as the accessible way to open the drawer.
Close confirmation
A nested confirmation dialog guards against losing work: it opens when the text typed into the drawer is about to be discarded.
Veto the close by driving open one way — pass :open and handle @update:open yourself. When a close is requested the listener runs; if you don't write the new value back, open keeps its old value and the drawer stays open. Open the confirmation there instead, so the prompt appears whether the user clicks the backdrop, presses Esc, hits a close button, or swipes to dismiss.
<script setup>
function setOpen(next) {
// veto: don't write it back, drawer stays open
if (!next && hasUnsavedChanges.value) return
open.value = next
}
</script>
<template>
<Drawer.Root :open="open" @update:open="setOpen"> ... </Drawer.Root>
</template>Action sheet with separate destructive action
An action sheet pairing a grouped list of actions with a separate, destructive action button.
Detached triggers
For a one-off, keep <Drawer.Trigger> inside <Drawer.Root>. When the content can't sit beside its trigger, detach it: create a handle with Drawer.createHandle() and pass it to both <Drawer.Trigger :handle="…"> and <Drawer.Root :handle="…">. They stay linked no matter where each lives in the tree.
<!-- [!code word::handle="demoDrawer"] -->
<script setup>
const demoDrawer = Drawer.createHandle()
</script>
<template>
<Drawer.Trigger :handle="demoDrawer">Open</Drawer.Trigger>
<Drawer.Root :handle="demoDrawer">
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Content>
<Drawer.Title>Drawer</Drawer.Title>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</template>To show different content depending on which trigger opened the drawer, pass a payload to each <Drawer.Trigger> and read it from the default slot on <Drawer.Root>. Give Drawer.createHandle() a type argument to type the payload:
<script setup lang="ts">
const demoDrawer = Drawer.createHandle<{ title: string }>()
</script>
<template>
<Drawer.Trigger :handle="demoDrawer" :payload="{ title: 'Profile' }">Profile</Drawer.Trigger>
<Drawer.Trigger :handle="demoDrawer" :payload="{ title: 'Settings' }">Settings</Drawer.Trigger>
<Drawer.Root v-slot="{ payload }" :handle="demoDrawer">
<Drawer.Portal>
<Drawer.Viewport>
<Drawer.Popup>
<Drawer.Content>
<Drawer.Title v-if="payload !== undefined">{{ payload.title }}</Drawer.Title>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Content>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
</template>Stacking and animations
The --nested-drawers CSS variable gives the stack depth; the frontmost drawer has index 0.
.drawer-popup {
--stack-step: 0.05;
--stack-scale: calc(1 - (var(--nested-drawers) * var(--stack-step)));
transform: translateY(var(--drawer-swipe-movement-y)) scale(var(--stack-scale));
}When stacked drawers have varying heights, use --drawer-height and --drawer-frontmost-height to keep collapsed drawers aligned with the frontmost one.
.drawer-popup {
--bleed: 3rem;
--stack-height: max(
0px,
calc(var(--drawer-frontmost-height, var(--drawer-height)) - var(--bleed))
);
height: var(--drawer-height, auto);
}
.drawer-popup[data-nested-drawer-open] {
height: calc(var(--stack-height) + var(--bleed));
overflow: hidden;
}Use data-nested-drawer-open with data-nested-drawer-swiping to fade parent drawer content to zero opacity, so it stays mounted and laid out during nested swipe interactions.
.drawer-content {
transition: opacity 300ms;
}
.drawer-popup[data-nested-drawer-open] .drawer-content {
opacity: 0;
}
.drawer-popup[data-nested-drawer-open][data-nested-drawer-swiping] .drawer-content {
opacity: 1;
}Use the --drawer-swipe-movement-x, --drawer-swipe-movement-y, and --drawer-snap-point-offset CSS variables for drag and snap offsets:
.drawer-popup[data-swipe-direction='right'] {
transform: translateX(var(--drawer-swipe-movement-x));
}
.drawer-popup[data-swipe-direction='down'] {
transform: translateY(calc(var(--drawer-snap-point-offset) + var(--drawer-swipe-movement-y)));
}Combine data-swipe-direction with data-ending-style to animate directional dismissal:
.drawer-popup[data-ending-style][data-swipe-direction='right'] {
transform: translateX(100%);
}
.drawer-popup[data-ending-style][data-swipe-direction='down'] {
transform: translateY(100%);
}Use --drawer-swipe-progress to fade the backdrop as the drawer is swiped, and --drawer-swipe-strength to scale release transition durations based on swipe velocity.
.drawer-backdrop {
--backdrop-opacity: 0.2;
opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress)));
}
.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;
}API reference
Provider
Tracks the open state of every drawer inside it, driving <Drawer.Indent> and <Drawer.IndentBackground>.
Doesn't render its own HTML element.
IndentBackground
A background layer placed before <Drawer.Indent>, shown behind the app while a drawer indents it.
Renders a <div> element.
Indent
A wrapper element intended to contain your app's main UI.
Renders a <div> element.
Root
Groups all parts of the drawer. Doesn't render its own HTML element.
Trigger
A button that opens the drawer.
Renders a <button> element.
SwipeArea
An invisible area that listens for swipe gestures to open the drawer.
Renders a <div> element.
Portal
A portal that moves the popup out to <body>, clear of ancestor clipping and stacking.
Renders a <div> element.
Backdrop
An overlay displayed beneath the popup.
Renders a <div> element.
Viewport
A positioning container for the drawer popup that can be made scrollable.
Owns the swipe gestures and the touch scroll locking, so <Drawer.Popup> must render inside it.
Renders a <div> element.
Popup
A container for the drawer contents.
Renders a <div> element.
Content
An inner container whose children can be selected with a mouse or pen without starting a swipe.
Renders a <div> element.
Title
A heading that labels the drawer.
Renders an <h2> element.
Description
A paragraph with additional information about the drawer.
Renders a <p> element.
Close
A button that closes the drawer.
Renders a <button> element.
Handle
Connects a <Drawer.Root> with detached <Drawer.Trigger> components, and controls the drawer imperatively. Pass a type argument to type the payload.
const drawer = Drawer.createHandle<Payload>()