Skip to content

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.

View Source 

Installation

Follow instructions for individual framework usage below

  • winduum 
  • winduum-elements 
  • winduum-stimulus 
  • winduum-vue 
  • winduum-react 

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

  • default 
  • content 
  • scroller 
  • noscript 
  • nosnap 

Props

  • default 
  • content 

Scripts

The Drawer script connects the dialog open flow with drawer snapping, swipe dismissal and trigger aria-expanded state.

Examples

Left

html
<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>
liquid
<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>
vue
<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>
tsx
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>
        </>
    )
}
html
<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>
liquid
<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>
vue
<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>
tsx
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

html
<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>
liquid
<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>
vue
<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>
tsx
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

html
<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>
liquid
<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>
vue
<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>
tsx
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.

html
<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>
liquid
<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>
vue
<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>
tsx
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.

html
<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>
liquid
<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>
vue
<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>
tsx
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.

html
<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().

js
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.

js
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.

Released under the MIT License.