Drawer
Slide-in modal panel from any edge.
Live demo
01Basic β right edge
Default 320px panel sliding from the right. Press Esc or click the backdrop to close β focus snaps back to the trigger.
All four edges
Custom size β 70vh bottom sheet
With a form (focus-trap proof)
Persistent β no Esc / backdrop dismiss
Focus restore on auto-close
Implementation
02<script lang="ts">
import Drawer from '$lib/components/Drawer.svelte';
β
let open = $state(false);
</script>
β
<button type="button" onclick={() => (open = true)}>Open filters</button>
β
<Drawer bind:open position="right" size="380px" ariaLabel="Filters">
<h2>Filters</h2>
<p>Drawer content. Press Escape or click outside to close.</p>
</Drawer>Drawer mounts a fixed-position panel that slides from any of four edges. On open it snapshots the active element, locks body scroll, and runs a manual focus trap so Tab and Shift+Tab cycle inside the dialog. On close it restores focus and unlocks scroll. Backdrop click and Escape both dismiss unless persistent is set; reduced-motion swaps the slide for a calm opacity fade.
Logic explainer
03What Does It Do? (Plain English)
A modal layer that slides in from one of the four screen edges β left, right, top, or bottom. Use it for mobile navigation menus, side panels for filters or settings, full-form bottom sheets, notification overlays, and anything that fits the "modal layer slides in from somewhere" pattern.
Think of it like a kitchen drawer that opens with a tug: it covers the underlying counter (the rest of your page), keeps your hands inside it until you push it shut, and slides cleanly back into the wall when you do. While the drawer is open, the page behind it is locked β you can't accidentally scroll it, you can't Tab into it, and AT only sees the drawer itself.
How It Works (Pseudo-Code)
state:
open = false // bindable
drawerEl = bound DOM ref
previouslyFocused = null // snapshot at open time
previousBodyOverflow = '' // snapshot at open time
derive sizeStyle:
if size is undefined: return ''
value = size as number β "{size}px"
| size as string β size verbatim // '70vh', '24rem', etc.
if position is left or right: return "width: {value};"
else: return "height: {value};"
events:
on open transitions false β true:
1. previouslyFocused = document.activeElement
2. previousBodyOverflow = body.style.overflow
3. body.style.overflow = 'hidden' // lock page scroll
4. requestAnimationFrame:
first = drawerEl.querySelector(TABBABLE_SELECTOR)
first.focus() // first tabbable inside
on open transitions true β false (cleanup):
1. cancel pending requestAnimationFrame
2. body.style.overflow = previousBodyOverflow // restore, don't blanket-clear
3. if previouslyFocused still in DOM:
previouslyFocused.focus() // return focus to trigger
on Escape (window keydown):
if open and not persistent:
open = false
onClose?()
on Tab (window keydown, while open):
tabbables = drawerEl.querySelectorAll(TABBABLE_SELECTOR)
if tabbables empty:
preventDefault // pin focus on drawer panel
else if Shift+Tab and active === first:
preventDefault; last.focus() // wrap backward
else if Tab and active === last:
preventDefault; first.focus() // wrap forward
// otherwise let the browser handle it
on backdrop click:
if not persistent:
open = false
onClose?()The whole open/close lifecycle lives inside a single $effect that exits early when open is false. Because the cleanup function captures the snapshots taken at open time, restore-on-close works even if the drawer was opened from a deeply nested trigger and the user navigates with the keyboard before closing.
Focus Trapping
The native <dialog> element with showModal() provides a built-in focus trap, ESC handling, and a ::backdrop pseudo-element. Drawer chooses a manual implementation for three reasons:
- Animation control.
<dialog>opens viashowModal()(imperative). Coordinating that with Svelte 5's declarative{#if open}plus CSS slide animations is awkward β the element has to mount, then receiveshowModal(), then animate, with no clean way to bind the lifecycle to a reactiveopenflag. - Cross-browser focus-trap consistency. Browser implementations of the native trap differ in subtle ways, especially with shadow DOM, iframes, and contenteditable regions. A handwritten trap behaves identically everywhere.
- Closer customisation. Drawer controls the backdrop element directly, so the click target, animation, and
pointer-eventsbehaviour are predictable rather than negotiated with the user agent.
The trade-off is roughly thirty lines of JS for the trap, body lock, and focus restore. The accessibility outcome is identical to a well-implemented native dialog.
[trigger button] ββ click βββΆ [drawer opens]
β
βΌ
first tabbable focused
β
βΌ
Tab βββΆ next tabbable
Shift+Tab βββΆ prev tabbable
β
Tab at last βββΆ wraps to first
Shift+Tab at first βββΆ wraps to last
β
βΌ
[drawer closes]
β
βΌ
previouslyFocused.focus()
(the original trigger)The tabbable selector matches the elements browsers consider focusable in normal Tab order:
a[href],
button:not([disabled]),
input:not([disabled]),
select:not([disabled]),
textarea:not([disabled]),
[tabindex]:not([tabindex="-1"])Disabled controls are excluded so the trap doesn't park focus on something the user can't interact with β browsers skip them naturally during Tab, and the trap follows that convention. The drawer panel itself carries tabindex="-1" so it can receive programmatic focus when the drawer has no tabbable content (think: read-only notification), without inserting itself into the Tab sequence.
Body scroll lock is snapshot-and-restore, not blanket-clear: if your CSS sets body { overflow: scroll; }, the drawer respects that on close. The same pattern applies to focus restoration β if the originally-focused element has been removed from the DOM by the time the drawer closes, focus falls through to <body> rather than throwing.
CSS Animation Strategy
Each edge has its own slide-in keyframe driven by transform, which stays on the GPU and never thrashes layout:
.drawer-right {
top: 0; bottom: 0; right: 0;
width: 320px; max-width: 90vw;
animation: drawer-slide-right 280ms cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes drawer-slide-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}The cubic-bezier(0.32, 0.72, 0, 1) curve is the iOS-style "ease out, settle hard" easing β fast at the start, decelerating into place. It's the standard for any UI that simulates physical sliding mass. The 280 ms duration is deliberately short; longer feels rubbery, shorter feels like a pop.
The backdrop fades independently over 200 ms with a linear opacity ramp, so the drawer arrives before the backdrop has fully darkened β the eye perceives the drawer as the foreground actor and the backdrop as a passive consequence.
Reduced-motion gets a calm fade instead of a slide:
@media (prefers-reduced-motion: reduce) {
.drawer-left,
.drawer-right,
.drawer-top,
.drawer-bottom {
animation: drawer-fade-in 180ms ease-out;
}
@keyframes drawer-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
}The drawer still appears with a visual cue β important for cognitive accessibility, since silently popping into existence is disorienting β but the horizontal/vertical movement that triggers vestibular discomfort is gone.
The size prop forwards as an inline style on the drawer element. Numbers become pixels (size={400} β width: 400px;), and any CSS length string passes through verbatim (size="70vh" for a bottom sheet at seventy percent of viewport height). The dimension swaps automatically based on position: left/right writes width, top/bottom writes height.
Distinct From MorphingDialog
Both are dismissable modal layers, but they're built for different jobs:
- Drawer slides in from a screen edge and is anchored to the viewport. It's used when content needs space β navigation menus, side filters, full forms β and where the relationship to the rest of the page is "this overlays everything". Origin: an edge.
- MorphingDialog animates from the trigger element itself, growing out of the card or button you clicked. It's used for visual continuity β "this expanded view is that card you tapped". Origin: a triggering element.
If you find yourself wanting a Drawer that's anchored to a card, you want MorphingDialog. If you want to "expand" a row from a list into a full-form panel, you want Drawer.
State Flow Diagram
ββββββββββββββββ
β CLOSED β
β open=false β
ββββββββ¬ββββββββ
β
β parent sets open = true
βΌ
ββββββββββββββββ
β OPENING β $effect runs:
β β β’ snapshot focus
β β β’ snapshot body.overflow
β β β’ body.overflow = 'hidden'
β β β’ rAF β focus first tabbable
ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββ
β OPEN β
β open=true β
β β Tab/Shift+Tab cycle inside
β β Escape closes (unless persistent)
β β Backdrop click closes (unless persistent)
ββββββββ¬ββββββββ
β
βββββββββββββββββββββΌβββββββββββββββββββββ
β Escape pressed β backdrop clicked β parent sets open = false
β (and !persistent) β (and !persistent) β
βΌ βΌ βΌ
ββββββββββββββββ
β CLOSING β $effect cleanup:
β β β’ cancelAnimationFrame
β β β’ body.overflow = previous
β β β’ restore previouslyFocused
β β β’ onClose?.()
ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββ
β CLOSED β
ββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Whether the drawer is visible. Bindable for parent control via bind:open. |
position |
'left' | 'right' | 'top' | 'bottom' |
'right' |
Which edge the drawer slides in from. |
size |
number | string |
320 (px default in CSS) |
Width (left/right) or height (top/bottom). Numbers become pixels; strings pass through as any CSS length ('70vh', '24rem'). |
persistent |
boolean |
false |
When true, backdrop click and Escape do not close the drawer β useful for forms where the user must explicitly cancel or submit. |
ariaLabel |
string |
'Drawer' |
Accessible name for the dialog. Ignored if ariaLabelledBy is set. |
ariaLabelledBy |
string |
undefined |
ID of an element inside the drawer (typically a heading) that names the dialog. Preferred over ariaLabel when the content already has a visible heading. |
onClose |
() => void |
undefined |
Fires when the drawer closes via Escape, backdrop click, or any cause that flips open to false. Use for analytics, stale-form cleanup, etc. |
children |
Snippet |
required | Drawer content. |
class |
string |
'' |
Extra classes appended to the drawer panel. |
Keyboard
| Key | Action |
|---|---|
Escape |
Closes the drawer (unless persistent). |
Tab |
Moves focus forward, trapped inside the drawer. |
Shift+Tab |
Moves focus backward, trapped inside the drawer. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Drawer opened with no tabbable content inside | First tab attempt is preventDefault'd; focus stays on the drawer panel itself (which has tabindex="-1"). The user can still Escape out. |
| Trigger element removed from the DOM while drawer is open | On close, document.body.contains(previouslyFocused) returns false; the focus restore is silently skipped and focus falls through to <body>. No exception. |
Page CSS sets body { overflow: scroll; } |
The drawer captures style.overflow at open, sets it to 'hidden', then restores the original value on close. Your stylesheet wins. |
User has prefers-reduced-motion: reduce |
Slide animation is replaced with a calm 180 ms opacity fade. The drawer still appears so users get a visual cue, just without horizontal/vertical motion. |
persistent set and user presses Escape |
Keypress is ignored; the drawer stays open. Same for backdrop click. The only way to close is for the parent to flip bind:open to false. |
size passed as a string like '70vh' |
Forwarded verbatim into the width or height inline style β any valid CSS length works (vh, rem, %, calc(...), etc.). |
| Drawer rendered server-side | The mount effect short-circuits when typeof document === 'undefined'. No focus, no scroll lock, no errors during SSR. |
ariaLabelledBy and ariaLabel both set |
ariaLabelledBy wins; aria-label is omitted from the rendered DOM so AT only reads the labelled-by element. |
Dependencies
- Svelte 5.x β
$state,$bindable,$derived.by,$effect,untrack, and snippets. The whole open/close lifecycle leans on$effect's cleanup function for restore-on-close. - Zero external dependencies β the slide animation is pure CSS, the focus trap is hand-rolled, and the backdrop is a plain
<div>. Fully copy-paste portable.
File Structure
src/lib/components/Drawer.svelte # implementation
src/lib/components/Drawer.md # this file (rendered inside ComponentPageShell)
src/routes/drawer/+page.svelte # demo page
src/lib/types.ts # DrawerProps + DrawerPosition (if extracted)API
04| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Bindable open/closed state. |
position | 'left' | 'right' | 'top' | 'bottom' | 'right' | Edge the drawer slides from. |
size | number | string | undefined | Numeric (px) or any CSS length string ('70vh'). |
persistent | boolean | false | When true, backdrop click and Escape no longer dismiss. |
ariaLabel | string | 'Drawer' | Accessible name when no labelled element is provided. |
ariaLabelledBy | string | undefined | ID of an element that labels the drawer. |
onClose | () => void | undefined | Callback fired whenever the drawer closes. |
children | Snippet | β | Drawer body content. |
class | string | '' | Extra CSS class on the panel. |