Drawer
Provides a scroll drawer that uses native HTML5 dialog and CSS scroll-snap property.
Can be dismissed with touch gestures on touch devices.
The drawer is opened with native Invoker Commands (command="show-modal" / command="request-close") and the scroll behavior is driven by the Drawer custom element from winduum-elements (or the winduum-stimulus controller). Thanks to the noscript variant, the drawer degrades gracefully even without JavaScript.
Installation
Follow instructions for individual framework usage below
Browser support
For older browsers cover Invoker Commands with invokers-polyfill and — when using winduum-elements — customized built-in elements with @webreflection/custom-elements-builtin. Missing scroll-initial-target support is handled by the showDrawer helper itself. See Polyfills.
Styles
Variants
Props
Scripts
The Drawer script connects the dialog open flow with drawer snapping, swipe dismissal and trigger aria-expanded state.
Examples
Left
<button class="x-button" command="show-modal" commandfor="drawerLeftElement">Show drawer</button>
<dialog class="x-drawer" is="x-drawer" id="drawerLeftElement" closedby="any">
<div class="x-drawer-scroller snap-x snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-part="content">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerLeftElement">Close drawer</button>
</nav>
</div>
</dialog><button class="x-button" command="show-modal" commandfor="drawerLeftElement">Show drawer</button>
<dialog data-controller="x-drawer" class="x-drawer" id="drawerLeftElement" closedby="any">
<div class="x-drawer-scroller snap-x snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-target="content">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerLeftElement">Close drawer</button>
</nav>
</div>
</dialog><script setup lang="ts">
import { ref, useId } from 'vue'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
const drawerLeft = useId()
const contentElement = ref<HTMLElement>()
</script>
<template>
<Button command="show-modal" :commandfor="drawerLeft">Show drawer</Button>
<Drawer :id="drawerLeft" closedby="any" :refs="{ contentElement }">
<DrawerScroller class="snap-x snap-mandatory">
<DrawerContent ref="contentElement" as="nav">
Drawer content
<Button class="muted" command="request-close" :commandfor="drawerLeft">
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</template>import { useId, useRef } from "react"
import { Drawer, DrawerContent, DrawerScroller } from "@/components/drawer"
import { Button } from "@/components/button"
export function Example() {
const drawerLeft = useId()
const contentElement = useRef<HTMLElement>(null)
return (
<>
<Button command="show-modal" commandfor={drawerLeft}>Show drawer</Button>
<Drawer id={drawerLeft} closedby="any" refs={{ contentElement }}>
<DrawerScroller className="snap-x snap-mandatory">
<DrawerContent ref={contentElement} as="nav">
Drawer content
<Button className="muted" command="request-close" commandfor={drawerLeft}>
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</>
)
}Right
<button class="x-button" command="show-modal" commandfor="drawerRightElement">Show drawer</button>
<dialog class="x-drawer" style="animation-direction: reverse" is="x-drawer" id="drawerRightElement" closedby="any" data-placement="right">
<div class="x-drawer-scroller after:order-first snap-x snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-part="content">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerRightElement">Close drawer</button>
</nav>
</div>
</dialog><button class="x-button" command="show-modal" commandfor="drawerRightElement">Show drawer</button>
<dialog data-controller="x-drawer" class="x-drawer" style="animation-direction: reverse" id="drawerRightElement" closedby="any" data-x-drawer-placement-value="right">
<div class="x-drawer-scroller after:order-first snap-x snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-target="content">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerRightElement">Close drawer</button>
</nav>
</div>
</dialog><script setup lang="ts">
import { ref, useId } from 'vue'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
const drawerId = useId()
const contentElement = ref<HTMLElement>()
</script>
<template>
<Button command="show-modal" :commandfor="drawerId">Show drawer</Button>
<Drawer :id="drawerId" closedby="any" placement="right" style="animation-direction: reverse" :refs="{ contentElement }">
<DrawerScroller class="after:order-first snap-x snap-mandatory">
<DrawerContent ref="contentElement" as="nav">
Drawer content
<Button class="muted" command="request-close" :commandfor="drawerId">
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</template>import { useId, useRef } from 'react'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
export function Example() {
const drawerId = useId()
const contentElement = useRef<HTMLElement>(null)
return (
<>
<Button command="show-modal" commandfor={drawerId}>Show drawer</Button>
<Drawer id={drawerId} closedby="any" placement="right" style={{ animationDirection: 'reverse' }} refs={{ contentElement }}>
<DrawerScroller className="after:order-first snap-x snap-mandatory">
<DrawerContent ref={contentElement} as="nav">
Drawer content
<Button className="muted" command="request-close" commandfor={drawerId}>
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</>
)
}Bottom
<button class="x-button" command="show-modal" commandfor="drawerBottomElement">Show drawer</button>
<dialog class="x-drawer" style="animation-direction: reverse" is="x-drawer" id="drawerBottomElement" closedby="any" data-placement="bottom">
<div class="x-drawer-scroller flex flex-col after:order-first snap-y snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-part="content" style="--x-drawer-content-inline-size: 100dvw;--x-drawer-content-block-size: calc(100vh - 4rem);">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerBottomElement">Close drawer</button>
</nav>
</div>
</dialog><button class="x-button" command="show-modal" commandfor="drawerBottomElement">Show drawer</button>
<dialog data-controller="x-drawer" class="x-drawer" style="animation-direction: reverse" id="drawerBottomElement" closedby="any" data-x-drawer-placement-value="bottom">
<div class="x-drawer-scroller flex flex-col after:order-first snap-y snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-target="content" style="--x-drawer-content-inline-size: 100dvw;--x-drawer-content-block-size: calc(100vh - 4rem);">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerBottomElement">Close drawer</button>
</nav>
</div>
</dialog><script setup lang="ts">
import { ref, useId } from 'vue'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
const drawerId = useId()
const contentElement = ref<HTMLElement>()
</script>
<template>
<Button command="show-modal" :commandfor="drawerId">Show drawer</Button>
<Drawer :id="drawerId" closedby="any" placement="bottom" style="animation-direction: reverse" :refs="{ contentElement }">
<DrawerScroller class="flex flex-col after:order-first snap-y snap-mandatory">
<DrawerContent ref="contentElement" as="nav" style="--x-drawer-content-inline-size: 100dvw; --x-drawer-content-block-size: calc(100vh - 4rem)">
Drawer content
<Button class="muted" command="request-close" :commandfor="drawerId">
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</template>import type { CSSProperties } from 'react'
import { useId, useRef } from 'react'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
export function Example() {
const drawerId = useId()
const contentElement = useRef<HTMLElement>(null)
return (
<>
<Button command="show-modal" commandfor={drawerId}>Show drawer</Button>
<Drawer id={drawerId} closedby="any" placement="bottom" style={{ animationDirection: 'reverse' }} refs={{ contentElement }}>
<DrawerScroller className="flex flex-col after:order-first snap-y snap-mandatory">
<DrawerContent ref={contentElement} as="nav" style={{ '--x-drawer-content-inline-size': '100dvw', '--x-drawer-content-block-size': 'calc(100vh - 4rem)' } as CSSProperties}>
Drawer content
<Button className="muted" command="request-close" commandfor={drawerId}>
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</>
)
}Top
<button class="x-button" command="show-modal" commandfor="drawerTopElement">Show drawer</button>
<dialog class="x-drawer" is="x-drawer" id="drawerTopElement" closedby="any" data-placement="top">
<div class="x-drawer-scroller flex flex-col snap-y snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-part="content" style="--x-drawer-content-inline-size: 100dvw;--x-drawer-content-block-size: calc(100vh - 4rem);">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerTopElement">Close drawer</button>
</nav>
</div>
</dialog><button class="x-button" command="show-modal" commandfor="drawerTopElement">Show drawer</button>
<dialog data-controller="x-drawer" class="x-drawer" id="drawerTopElement" closedby="any" data-x-drawer-placement-value="top">
<div class="x-drawer-scroller flex flex-col snap-y snap-mandatory">
<nav class="x-drawer-content" data-x-drawer-target="content" style="--x-drawer-content-inline-size: 100dvw;--x-drawer-content-block-size: calc(100vh - 4rem);">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerTopElement">Close drawer</button>
</nav>
</div>
</dialog><script setup lang="ts">
import { ref, useId } from 'vue'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
const drawerId = useId()
const contentElement = ref<HTMLElement>()
</script>
<template>
<Button command="show-modal" :commandfor="drawerId">Show drawer</Button>
<Drawer :id="drawerId" closedby="any" placement="top" :refs="{ contentElement }">
<DrawerScroller class="flex flex-col snap-y snap-mandatory">
<DrawerContent ref="contentElement" as="nav" style="--x-drawer-content-inline-size: 100dvw; --x-drawer-content-block-size: calc(100vh - 4rem)">
Drawer content
<Button class="muted" command="request-close" :commandfor="drawerId">
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</template>import type { CSSProperties } from 'react'
import { useId, useRef } from 'react'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
export function Example() {
const drawerId = useId()
const contentElement = useRef<HTMLElement>(null)
return (
<>
<Button command="show-modal" commandfor={drawerId}>Show drawer</Button>
<Drawer id={drawerId} closedby="any" placement="top" refs={{ contentElement }}>
<DrawerScroller className="flex flex-col snap-y snap-mandatory">
<DrawerContent ref={contentElement} as="nav" style={{ '--x-drawer-content-inline-size': '100dvw', '--x-drawer-content-block-size': 'calc(100vh - 4rem)' } as CSSProperties}>
Drawer content
<Button className="muted" command="request-close" commandfor={drawerId}>
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</>
)
}Dialog
Responsive variant that behaves as a bottom drawer below the xl container breakpoint (576px) and as a dialog in larger containers.
<button class="x-button" command="show-modal" commandfor="drawerDialogElement">Show dialog drawer</button>
<dialog class="x-dialog drawer @max-xl:bg-transparent @max-xl:p-0 @max-xl:backdrop:block" style="animation-direction: reverse" is="x-drawer" id="drawerDialogElement" closedby="any" data-placement="bottom">
<div class="x-drawer-scroller scroll-pt-6 flex flex-col after:order-first snap-y snap-mandatory @xl:contents @xl:after:content-none noscript:after:content-none">
<section class="x-dialog-content @max-xl:mt-6 @max-xl:opacity-100 @max-xl:transform-none shrink-0 @max-xl:snap-end @max-xl:w-dvw @max-xl:min-h-[calc(100dvh-4rem)] @max-xl:rounded-b-none" data-x-drawer-part="content">
<div class="x-heading">Dialog drawer</div>
<br>
<div class="x-text">
<p>This component behaves as a bottom drawer on small screens and as a dialog on larger screens.</p>
</div>
<br>
<button class="x-button muted @xl:hidden" command="request-close" commandfor="drawerDialogElement">Close drawer</button>
<button class="x-button muted @max-xl:hidden" command="close" commandfor="drawerDialogElement">Close dialog</button>
</section>
</div>
</dialog><button class="x-button" command="show-modal" commandfor="drawerDialogElement">Show dialog drawer</button>
<dialog data-controller="x-drawer" class="x-dialog drawer @max-xl:bg-transparent @max-xl:p-0 @max-xl:backdrop:block" style="animation-direction: reverse" id="drawerDialogElement" closedby="any" data-x-drawer-placement-value="bottom">
<div class="x-drawer-scroller scroll-pt-6 flex flex-col after:order-first snap-y snap-mandatory @xl:contents @xl:after:content-none noscript:after:content-none">
<section class="x-dialog-content @max-xl:mt-6 @max-xl:opacity-100 @max-xl:transform-none shrink-0 @max-xl:snap-end @max-xl:w-dvw @max-xl:min-h-[calc(100dvh-4rem)] @max-xl:rounded-b-none" data-x-drawer-target="content">
<div class="x-heading">Dialog drawer</div>
<br>
<div class="x-text">
<p>This component behaves as a bottom drawer on small screens and as a dialog on larger screens.</p>
</div>
<br>
<button class="x-button muted @xl:hidden" command="request-close" commandfor="drawerDialogElement">Close drawer</button>
<button class="x-button muted @max-xl:hidden" command="close" commandfor="drawerDialogElement">Close dialog</button>
</section>
</div>
</dialog><script setup lang="ts">
import { ref, useId } from 'vue'
import { Drawer, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
const drawerId = useId()
const contentElement = ref<HTMLElement>()
</script>
<template>
<Button command="show-modal" :commandfor="drawerId">Show dialog drawer</Button>
<Drawer
:id="drawerId"
closedby="any"
placement="bottom"
class="x-dialog drawer @max-xl:bg-transparent @max-xl:p-0 @max-xl:backdrop:block"
style="animation-direction: reverse"
:refs="{ contentElement }"
>
<DrawerScroller class="scroll-pt-6 flex flex-col after:order-first snap-y snap-mandatory @xl:contents @xl:after:content-none noscript:after:content-none">
<section ref="contentElement" class="x-dialog-content @max-xl:mt-6 @max-xl:opacity-100 @max-xl:transform-none shrink-0 @max-xl:snap-end @max-xl:w-dvw @max-xl:min-h-[calc(100dvh-4rem)] @max-xl:rounded-b-none">
<div class="x-heading">Dialog drawer</div>
<br>
<div class="x-text">
<p>This component behaves as a bottom drawer on small screens and as a dialog on larger screens.</p>
</div>
<br>
<Button class="muted @xl:hidden" command="request-close" :commandfor="drawerId">Close drawer</Button>
<Button class="muted @max-xl:hidden" command="close" :commandfor="drawerId">Close dialog</Button>
</section>
</DrawerScroller>
</Drawer>
</template>import { useId, useRef } from 'react'
import { Drawer, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
export function Example() {
const drawerId = useId()
const contentElement = useRef<HTMLElement>(null)
return (
<>
<Button command="show-modal" commandfor={drawerId}>Show dialog drawer</Button>
<Drawer
id={drawerId}
closedby="any"
placement="bottom"
className="x-dialog drawer @max-xl:bg-transparent @max-xl:p-0 @max-xl:backdrop:block"
style={{ animationDirection: 'reverse' }}
refs={{ contentElement }}
>
<DrawerScroller className="scroll-pt-6 flex flex-col after:order-first snap-y snap-mandatory @xl:contents @xl:after:content-none noscript:after:content-none">
<section ref={contentElement} className="x-dialog-content @max-xl:mt-6 @max-xl:opacity-100 @max-xl:transform-none shrink-0 @max-xl:snap-end @max-xl:w-dvw @max-xl:min-h-[calc(100dvh-4rem)] @max-xl:rounded-b-none">
<div className="x-heading">Dialog drawer</div>
<br />
<div className="x-text">
<p>This component behaves as a bottom drawer on small screens and as a dialog on larger screens.</p>
</div>
<br />
<Button className="muted @xl:hidden" command="request-close" commandfor={drawerId}>Close drawer</Button>
<Button className="muted @max-xl:hidden" command="close" commandfor={drawerId}>Close dialog</Button>
</section>
</DrawerScroller>
</Drawer>
</>
)
}No Script
Fully animated drawer when JavaScript is disabled.
<button class="x-button" command="show-modal" commandfor="drawerNoScriptElement">Show drawer</button>
<dialog class="x-drawer noscript:starting:-translate-x-full noscript:not-open:-translate-x-full noscript:duration-500" is="x-drawer" id="drawerNoScriptElement" closedby="any">
<div class="x-drawer-scroller snap-x snap-mandatory noscript:after:content-none">
<nav class="x-drawer-content" data-x-drawer-part="content">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerNoScriptElement">Close drawer</button>
</nav>
</div>
</dialog><button class="x-button" command="show-modal" commandfor="drawerNoScriptElement">Show drawer</button>
<dialog data-controller="x-drawer" class="x-drawer noscript:starting:-translate-x-full noscript:not-open:-translate-x-full noscript:duration-500" id="drawerNoScriptElement" closedby="any">
<div class="x-drawer-scroller snap-x snap-mandatory noscript:after:content-none">
<nav class="x-drawer-content" data-x-drawer-target="content">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerNoScriptElement">Close drawer</button>
</nav>
</div>
</dialog><script setup lang="ts">
import { ref, useId } from 'vue'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
const drawerId = useId()
const contentElement = ref<HTMLElement>()
</script>
<template>
<Button command="show-modal" :commandfor="drawerId">Show drawer</Button>
<Drawer :id="drawerId" closedby="any" class="noscript:starting:-translate-x-full noscript:not-open:-translate-x-full noscript:duration-500" :refs="{ contentElement }">
<DrawerScroller class="snap-x snap-mandatory noscript:after:content-none">
<DrawerContent ref="contentElement" as="nav">
Drawer content
<Button class="muted" command="request-close" :commandfor="drawerId">
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</template>import { useId, useRef } from 'react'
import { Drawer, DrawerContent, DrawerScroller } from '@/components/drawer'
import { Button } from '@/components/button'
export function Example() {
const drawerId = useId()
const contentElement = useRef<HTMLElement>(null)
return (
<>
<Button command="show-modal" commandfor={drawerId}>Show drawer</Button>
<Drawer id={drawerId} closedby="any" className="noscript:starting:-translate-x-full noscript:not-open:-translate-x-full noscript:duration-500" refs={{ contentElement }}>
<DrawerScroller className="snap-x snap-mandatory noscript:after:content-none">
<DrawerContent ref={contentElement} as="nav">
Drawer content
<Button className="muted" command="request-close" commandfor={drawerId}>
Close drawer
</Button>
</DrawerContent>
</DrawerScroller>
</Drawer>
</>
)
}No Snap
CSS only drawer variant that does not need JavaScript and works the same way as No Script.
<button class="x-button" command="show-modal" commandfor="drawerNoSnapElement">Show drawer</button>
<dialog class="x-drawer no-snap starting:-translate-x-full not-open:-translate-x-full duration-500" id="drawerNoSnapElement" closedby="any">
<div class="x-drawer-scroller after:content-none">
<nav class="x-drawer-content">
Drawer content
<button class="x-button muted" command="request-close" commandfor="drawerNoSnapElement">Close drawer</button>
</nav>
</div>
</dialog>JavaScript API
Low-level helpers used by winduum-elements and winduum-stimulus — you can use them to build your own integration. All functions take a placement of 'left' | 'right' | 'top' | 'bottom' and operate on the scroller element (.x-drawer-scroller).
showDrawer
- Type:
(element: HTMLElement, placement: 'left' | 'right' | 'top' | 'bottom') => Promise<void> - Kind:
async
Scrolls the drawer scroller to its open state. Resets the scroll position first in browsers without scroll-initial-target support. Call it after dialog.showModal().
import { showDrawer } from 'winduum/src/components/drawer'
dialogElement.showModal()
await showDrawer(dialogElement.firstElementChild, 'left')closeDrawer
- Type:
(element: HTMLElement, placement: 'left' | 'right' | 'top' | 'bottom') => void - Kind:
sync
Scrolls the drawer scroller to its closed state — the dismiss animation is handled by scroll snapping, and the dialog is closed by drawerObserver once the content leaves the viewport.
scrollDrawer
- Type:
(element: HTMLElement, placement: 'left' | 'right' | 'top' | 'bottom', reverse?: boolean, behavior?: 'auto' | 'instant') => void - Kind:
sync
Scrolls the drawer scroller to the open (or closed, with reverse: true) position.
drawerEvents
- Type:
(element: HTMLDialogElement, contentElement: HTMLElement, placement: 'left' | 'right' | 'top' | 'bottom', signal?: AbortSignal) => void - Kind:
sync
Wires up dialog events — closes the drawer with the scroll animation on cancel (Esc) and on click outside the content.
drawerObserver
- Type:
(element: HTMLDialogElement, placement: 'left' | 'right' | 'top' | 'bottom') => IntersectionObserver - Kind:
sync
Returns an IntersectionObserver that closes the dialog once the drawer content is scrolled/swiped out of view. Observe the content element with it.
import { drawerObserver } from 'winduum/src/components/drawer'
const observer = drawerObserver(dialogElement, 'left')
observer.observe(contentElement)drawerProperties
- Type:
(element: HTMLElement, placement: 'left' | 'right' | 'top' | 'bottom') => ['top' | 'left', number, number] - Kind:
sync
Returns the scroll axis and the open/closed scroll distances for the given placement.
isVerticalDrawer
- Type:
(placement: 'left' | 'right' | 'top' | 'bottom') => boolean - Kind:
sync
Returns true for top and bottom placements.