Beta, expect API change, and bugs. Hit one? Tell us.

Skip to content

Drawer

A panel from a screen edge.

<script setup lang="ts">
import { Drawer } from '@shardsui/vue/drawer'
</script>

<template>
  <Drawer.Root swipe-direction="right">
    <Drawer.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Notes
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.2] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Drawer.Viewport
        class="fixed inset-0 flex items-stretch justify-end p-(--viewport-padding) [--viewport-padding:0px] supports-[-webkit-touch-callout:none]:[--viewport-padding:0.625rem]"
      >
        <Drawer.Popup
          class="relative -mr-12 h-full w-92 max-w-[calc(100vw-3rem+3rem)] transform-[translateX(var(--drawer-swipe-movement-x))] touch-auto overflow-y-auto overscroll-contain bg-gray-50 p-4 pr-18 text-gray-900 outline-1 outline-gray-200 transition-transform duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--bleed:3rem] data-ending-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-swiping:select-none supports-[-webkit-touch-callout:none]:mr-0 supports-[-webkit-touch-callout:none]:w-80 supports-[-webkit-touch-callout:none]:max-w-[calc(100vw-20px)] supports-[-webkit-touch-callout:none]:rounded-lg supports-[-webkit-touch-callout:none]:pr-6 supports-[-webkit-touch-callout:none]:[--bleed:0px]"
        >
          <Drawer.Content class="mx-auto w-full max-w-lg">
            <Drawer.Title class="-mt-1.5 mb-1 text-base font-semibold">Notes</Drawer.Title>
            <Drawer.Description class="mb-4 text-sm text-gray-600">
              The panel slides in from the edge. Swipe it aside to dismiss.
            </Drawer.Description>
            <ul class="mb-4 grid gap-2 text-sm text-gray-700">
              <li>
                Kerning adjusts the space between two specific letters; tracking spaces a whole run
                evenly
              </li>
              <li>Ease-out for elements entering the screen; ease-in for ones leaving it</li>
              <li>A semantic token names a color's purpose — --color-border-subtle, not #e0e0e0</li>
            </ul>
            <div class="flex justify-end gap-4">
              <Drawer.Close
                class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
              >
                Close
              </Drawer.Close>
            </div>
          </Drawer.Content>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  </Drawer.Root>
</template>

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>
<script setup lang="ts">
import { Drawer } from '@shardsui/vue/drawer'
</script>

<template>
  <Drawer.Root swipe-direction="left">
    <Drawer.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Type scale
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.2] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Drawer.Viewport
        class="fixed inset-0 flex items-stretch justify-start p-(--viewport-padding) [--viewport-padding:0px] supports-[-webkit-touch-callout:none]:[--viewport-padding:0.625rem]"
      >
        <Drawer.Popup
          class="relative -ml-12 h-full w-92 max-w-[calc(100vw-3rem+3rem)] transform-[translateX(var(--drawer-swipe-movement-x))] touch-auto overflow-y-auto overscroll-contain bg-gray-50 p-4 pl-18 text-gray-900 outline-1 outline-gray-200 transition-transform duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--bleed:3rem] data-ending-style:transform-[translateX(calc(-100%+var(--bleed)-var(--viewport-padding)-2px))] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateX(calc(-100%+var(--bleed)-var(--viewport-padding)-2px))] data-swiping:select-none supports-[-webkit-touch-callout:none]:ml-0 supports-[-webkit-touch-callout:none]:w-80 supports-[-webkit-touch-callout:none]:max-w-[calc(100vw-20px)] supports-[-webkit-touch-callout:none]:rounded-lg supports-[-webkit-touch-callout:none]:pl-6 supports-[-webkit-touch-callout:none]:[--bleed:0px]"
        >
          <Drawer.Content class="mx-auto w-full max-w-lg">
            <Drawer.Title class="-mt-1.5 mb-1 text-base font-semibold">Type scale</Drawer.Title>
            <Drawer.Description class="mb-4 text-sm text-gray-600">
              Common ratios for sizing headings and body text.
            </Drawer.Description>
            <ul class="mb-4 grid gap-2 text-sm text-gray-700">
              <li>Minor third — 1.2</li>
              <li>Perfect fourth — 1.333</li>
              <li>Golden ratio — 1.618</li>
            </ul>
            <div class="flex justify-end gap-4">
              <Drawer.Close
                class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
              >
                Close
              </Drawer.Close>
            </div>
          </Drawer.Content>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  </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.

<script setup lang="ts">
import { Drawer } from '@shardsui/vue/drawer'
</script>

<template>
  <Drawer.Root>
    <Drawer.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Open group
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.2] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Drawer.Viewport class="fixed inset-0 flex items-end justify-center">
        <Drawer.Popup
          class="group/popup relative -mb-12 h-(--drawer-height,auto) max-h-[calc(80vh+3rem)] w-full origin-[50%_calc(100%-var(--bleed))] transform-[translateY(calc(var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--shrink)*var(--height))))_scale(var(--scale))] touch-auto overflow-y-auto overscroll-contain rounded-t-2xl bg-gray-50 px-4 pt-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0)+3rem)] text-gray-900 outline-1 outline-gray-200 [--bleed:3rem] [--height:max(0px,calc(var(--drawer-frontmost-height,var(--drawer-height))-var(--bleed)))] [--peek:1rem] [--scale-base:calc(max(0,1-(var(--nested-drawers)*var(--stack-step))))] [--scale:clamp(0,calc(var(--scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--shrink:calc(1-var(--scale))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-step:0.05] [transition:transform_450ms_cubic-bezier(0.32,0.72,0,1),height_450ms_cubic-bezier(0.32,0.72,0,1),opacity_450ms_cubic-bezier(0.32,0.72,0,1)] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-transparent after:transition-[background-color] after:duration-450 after:ease-[cubic-bezier(0.32,0.72,0,1)] after:content-[''] data-ending-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-nested-drawer-open:h-[calc(var(--height)+var(--bleed))] data-nested-drawer-open:overflow-hidden data-nested-drawer-open:after:bg-black/5 data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-swiping:duration-0 data-swiping:select-none data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*0.4s)]"
        >
          <div
            class="mx-auto mb-4 h-1 w-12 rounded-full bg-gray-300 transition-opacity duration-200 group-data-nested-drawer-open/popup:opacity-0 group-data-nested-drawer-swiping/popup:opacity-100"
          ></div>
          <Drawer.Content
            class="mx-auto w-full max-w-lg transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] group-data-nested-drawer-open/popup:opacity-0 group-data-nested-drawer-swiping/popup:opacity-100"
          >
            <Drawer.Title class="mb-1 text-center text-base font-semibold">Group</Drawer.Title>
            <Drawer.Description class="mb-4 text-center text-sm text-gray-600">
              Nested drawers stack while each stays independently focus managed.
            </Drawer.Description>

            <div class="flex items-center justify-end gap-4">
              <div class="mr-auto">
                <Drawer.Root>
                  <Drawer.Trigger
                    class="-m-0.5 rounded px-1.5 py-0.5 text-sm text-gray-950 hover:bg-gray-950/5 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-950/10"
                  >
                    Invite member
                  </Drawer.Trigger>
                  <Drawer.Portal>
                    <Drawer.Viewport class="fixed inset-0 flex items-end justify-center">
                      <Drawer.Popup
                        class="group/popup relative -mb-12 h-(--drawer-height,auto) max-h-[calc(80vh+3rem)] w-full origin-[50%_calc(100%-var(--bleed))] transform-[translateY(calc(var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--shrink)*var(--height))))_scale(var(--scale))] touch-auto overflow-y-auto overscroll-contain rounded-t-2xl bg-gray-50 px-4 pt-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0)+3rem)] text-gray-900 outline-1 outline-gray-200 [--bleed:3rem] [--height:max(0px,calc(var(--drawer-frontmost-height,var(--drawer-height))-var(--bleed)))] [--peek:1rem] [--scale-base:calc(max(0,1-(var(--nested-drawers)*var(--stack-step))))] [--scale:clamp(0,calc(var(--scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--shrink:calc(1-var(--scale))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-step:0.05] [transition:transform_450ms_cubic-bezier(0.32,0.72,0,1),height_450ms_cubic-bezier(0.32,0.72,0,1),opacity_450ms_cubic-bezier(0.32,0.72,0,1)] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-transparent after:transition-[background-color] after:duration-450 after:ease-[cubic-bezier(0.32,0.72,0,1)] after:content-[''] data-ending-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-nested-drawer-open:h-[calc(var(--height)+var(--bleed))] data-nested-drawer-open:overflow-hidden data-nested-drawer-open:after:bg-black/5 data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-swiping:duration-0 data-swiping:select-none data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*0.4s)]"
                      >
                        <div
                          class="mx-auto mb-4 h-1 w-12 rounded-full bg-gray-300 transition-opacity duration-200 group-data-nested-drawer-open/popup:opacity-0 group-data-nested-drawer-swiping/popup:opacity-100"
                        ></div>
                        <Drawer.Content
                          class="mx-auto w-full max-w-lg transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] group-data-nested-drawer-open/popup:opacity-0 group-data-nested-drawer-swiping/popup:opacity-100"
                        >
                          <Drawer.Title class="mb-1 text-center text-base font-semibold">
                            Invite member
                          </Drawer.Title>
                          <Drawer.Description class="mb-4 text-center text-sm text-gray-600">
                            This drawer is taller to show variable-height stacking.
                          </Drawer.Description>

                          <div class="mb-4 grid gap-1.5 text-left">
                            <label class="text-sm font-semibold text-gray-700" for="invite-email">
                              Email
                            </label>
                            <input
                              id="invite-email"
                              class="w-full rounded-md border border-gray-200 bg-gray-50 px-2.5 py-2 text-sm font-normal text-gray-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 any-pointer-coarse:text-base"
                              type="email"
                              value="member@example.com"
                            />
                          </div>

                          <div class="mb-4 grid gap-1.5 text-left">
                            <label class="text-sm font-semibold text-gray-700" for="invite-note">
                              Note
                            </label>
                            <textarea
                              id="invite-note"
                              class="w-full resize-y rounded-md border border-gray-200 bg-gray-50 px-2.5 py-2 text-sm font-normal text-gray-900 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 any-pointer-coarse:text-base"
                              rows="{3}"
                            >
Join us for the next review session!</textarea>
                          </div>

                          <div class="flex items-center justify-between">
                            <Drawer.Root>
                              <Drawer.Trigger
                                class="-m-0.5 rounded px-1.5 py-0.5 text-sm text-gray-950 hover:bg-gray-950/5 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-950/10"
                              >
                                Set permissions
                              </Drawer.Trigger>
                              <Drawer.Portal>
                                <Drawer.Viewport
                                  class="fixed inset-0 flex items-end justify-center"
                                >
                                  <Drawer.Popup
                                    class="group/popup relative -mb-12 h-(--drawer-height,auto) max-h-[calc(80vh+3rem)] w-full origin-[50%_calc(100%-var(--bleed))] transform-[translateY(calc(var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--shrink)*var(--height))))_scale(var(--scale))] touch-auto overflow-y-auto overscroll-contain rounded-t-2xl bg-gray-50 px-4 pt-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0)+3rem)] text-gray-900 outline-1 outline-gray-200 [--bleed:3rem] [--height:max(0px,calc(var(--drawer-frontmost-height,var(--drawer-height))-var(--bleed)))] [--peek:1rem] [--scale-base:calc(max(0,1-(var(--nested-drawers)*var(--stack-step))))] [--scale:clamp(0,calc(var(--scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--shrink:calc(1-var(--scale))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-step:0.05] [transition:transform_450ms_cubic-bezier(0.32,0.72,0,1),height_450ms_cubic-bezier(0.32,0.72,0,1),opacity_450ms_cubic-bezier(0.32,0.72,0,1)] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:bg-transparent after:transition-[background-color] after:duration-450 after:ease-[cubic-bezier(0.32,0.72,0,1)] after:content-[''] data-ending-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-nested-drawer-open:h-[calc(var(--height)+var(--bleed))] data-nested-drawer-open:overflow-hidden data-nested-drawer-open:after:bg-black/5 data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-swiping:duration-0 data-swiping:select-none data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*0.4s)]"
                                  >
                                    <div
                                      class="mx-auto mb-4 h-1 w-12 rounded-full bg-gray-300 transition-opacity duration-200 group-data-nested-drawer-open/popup:opacity-0 group-data-nested-drawer-swiping/popup:opacity-100"
                                    ></div>
                                    <Drawer.Content
                                      class="mx-auto w-full max-w-lg transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] group-data-nested-drawer-open/popup:opacity-0 group-data-nested-drawer-swiping/popup:opacity-100"
                                    >
                                      <Drawer.Title
                                        class="mb-1 text-center text-base font-semibold"
                                      >
                                        Permissions
                                      </Drawer.Title>
                                      <Drawer.Description
                                        class="mb-4 text-center text-sm text-gray-600"
                                      >
                                        The stack keeps growing — each drawer scales the ones
                                        beneath it.
                                      </Drawer.Description>

                                      <div class="mb-4 grid gap-2 text-left">
                                        <label
                                          v-for="role in ['Editor', 'Reviewer', 'Viewer']"
                                          :key="role"
                                          class="flex items-center gap-2.5 text-sm text-gray-700"
                                        >
                                          <input
                                            type="radio"
                                            name="role"
                                            :value="role"
                                            :checked="role === 'Editor'"
                                            class="size-4 accent-gray-900"
                                          />
                                          {{ role }}
                                        </label>
                                      </div>

                                      <div class="flex justify-end">
                                        <Drawer.Close
                                          class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                                        >
                                          Done
                                        </Drawer.Close>
                                      </div>
                                    </Drawer.Content>
                                  </Drawer.Popup>
                                </Drawer.Viewport>
                              </Drawer.Portal>
                            </Drawer.Root>

                            <Drawer.Close
                              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                            >
                              Send invite
                            </Drawer.Close>
                          </div>
                        </Drawer.Content>
                      </Drawer.Popup>
                    </Drawer.Viewport>
                  </Drawer.Portal>
                </Drawer.Root>
              </div>

              <Drawer.Close
                class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
              >
                Close
              </Drawer.Close>
            </div>
          </Drawer.Content>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  </Drawer.Root>
</template>

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)));
}
<script setup lang="ts">
import { Drawer } from '@shardsui/vue/drawer'

const TOP_MARGIN_REM = 1
const VISIBLE_SNAP_POINTS_REM = [18]

function toViewportSnapPoint(heightRem: number) {
  return `${heightRem + TOP_MARGIN_REM}rem`
}

const snapPoints = [...VISIBLE_SNAP_POINTS_REM.map(toViewportSnapPoint), 1]

const rows = Array.from({ length: 16 }, (_, i) => i)
</script>

<template>
  <Drawer.Root :snap-points="snapPoints">
    <Drawer.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      List
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.2] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Drawer.Viewport class="fixed inset-0 flex touch-none items-end justify-center">
        <Drawer.Popup
          class="relative flex max-h-[calc(100dvh-var(--top-margin))] min-h-0 w-full transform-[translateY(calc(var(--drawer-snap-point-offset)+var(--drawer-swipe-movement-y)))] touch-none flex-col overflow-visible rounded-t-2xl bg-gray-50 pb-[max(0px,calc(var(--drawer-snap-point-offset)+var(--drawer-swipe-movement-y)))] text-gray-900 shadow-xl outline-1 outline-gray-200 transition-[transform,box-shadow] duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--bleed:3rem] after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-(--bleed) after:bg-gray-50 after:content-[''] data-ending-style:transform-[translateY(calc(100%+2px))] data-ending-style:pb-0 data-ending-style:shadow-none data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateY(calc(100%+2px))] data-starting-style:pb-0 data-starting-style:shadow-none data-swiping:select-none"
          :style="{ '--top-margin': `${TOP_MARGIN_REM}rem` }"
        >
          <div class="shrink-0 touch-none border-b border-gray-200 px-4 pt-3.5 pb-3">
            <div class="mx-auto h-1 w-12 rounded-full bg-gray-300"></div>
            <Drawer.Title class="mt-2.5 text-center text-base font-semibold">List</Drawer.Title>
          </div>
          <Drawer.Content
            class="min-h-0 flex-1 touch-auto overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1.5rem+env(safe-area-inset-bottom,0))]"
          >
            <div class="mx-auto w-full max-w-87.5">
              <Drawer.Description class="mb-4 text-center text-sm text-gray-600">
                Drag the sheet to snap between a compact peek and a near full-height view.
              </Drawer.Description>
              <ul class="mb-4 grid gap-2">
                <li v-for="row in rows" :key="row" class="h-12 rounded-xl bg-gray-100"></li>
              </ul>
              <div class="flex items-center justify-end gap-4">
                <Drawer.Close
                  class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                >
                  Close
                </Drawer.Close>
              </div>
            </div>
          </Drawer.Content>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  </Drawer.Root>
</template>

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.

<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { Drawer } from '@shardsui/vue/drawer'

const portalContainer = useTemplateRef<HTMLDivElement>('portalContainer')
</script>

<template>
  <Drawer.Provider>
    <div ref="portalContainer" class="relative w-full overflow-hidden [--bleed:3rem]">
      <Drawer.IndentBackground class="absolute inset-0 bg-black" />
      <Drawer.Indent
        class="relative min-h-80 origin-[center_top] transform-[scale(1)_translateY(0)] border border-gray-200 bg-gray-50 p-4 text-gray-900 duration-[calc(400ms*var(--indent-transition)),calc(250ms*var(--indent-transition))] will-change-transform [--indent-progress:var(--drawer-swipe-progress)] [--indent-radius:calc(1rem*(1-var(--indent-progress)))] [--indent-transition:calc(1-clamp(0,calc(var(--drawer-swipe-progress)*100000),1))] [transition:transform_0.4s_cubic-bezier(0.32,0.72,0,1),border-radius_0.25s_cubic-bezier(0.32,0.72,0,1)] data-active:transform-[scale(calc(0.98+(0.02*var(--indent-progress))))_translateY(calc(0.5rem*(1-var(--indent-progress))))] data-active:rounded-t-(--indent-radius)"
      >
        <div class="flex min-h-80 items-center justify-center">
          <Drawer.Root :modal="false">
            <Drawer.Trigger
              class="box-border flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Filter
            </Drawer.Trigger>
            <Drawer.Portal :container="portalContainer">
              <Drawer.Backdrop
                class="absolute inset-0 bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.2] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0"
              />
              <Drawer.Viewport class="absolute inset-0 flex items-end justify-center">
                <Drawer.Popup
                  class="-mb-(--bleed) box-border max-h-[calc(80vh+var(--bleed))] w-full transform-[translateY(var(--drawer-swipe-movement-y))] overflow-y-auto overscroll-contain rounded-t-2xl bg-gray-50 px-4 py-3 pb-[calc(1.5rem+env(safe-area-inset-bottom,0)+var(--bleed))] text-gray-900 outline-1 outline-gray-200 transition-transform duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateY(calc(100%-var(--bleed)+2px))] data-swiping:select-none"
                >
                  <div class="mx-auto mb-4 h-1 w-12 rounded-full bg-gray-300"></div>
                  <Drawer.Content class="mx-auto w-full max-w-lg">
                    <Drawer.Title
                      class="mt-0 mb-1 text-center text-base/7 font-semibold tracking-normal"
                    >
                      Filters
                    </Drawer.Title>
                    <Drawer.Description class="mb-4 text-center text-sm/6 text-gray-600">
                      The page behind scales back while this drawer is open.
                    </Drawer.Description>
                    <div class="flex justify-center gap-4">
                      <Drawer.Close
                        class="box-border flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                      >
                        Apply
                      </Drawer.Close>
                    </div>
                  </Drawer.Content>
                </Drawer.Popup>
              </Drawer.Viewport>
            </Drawer.Portal>
          </Drawer.Root>
        </div>
      </Drawer.Indent>
    </div>
  </Drawer.Provider>
</template>

Non-modal

Set :modal="false" to opt out of focus trapping and disablePointerDismissal to keep the drawer open on outside clicks.

<script setup lang="ts">
import { Drawer } from '@shardsui/vue/drawer'
</script>

<template>
  <Drawer.Root swipe-direction="right" :modal="false" disable-pointer-dismissal>
    <Drawer.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Open transcript
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Viewport
        class="pointer-events-none fixed inset-0 flex items-stretch justify-end p-(--viewport-padding) [--viewport-padding:0px] supports-[-webkit-touch-callout:none]:[--viewport-padding:0.625rem]"
      >
        <Drawer.Popup
          class="pointer-events-auto -mr-12 h-full w-92 max-w-[calc(100vw-3rem+3rem)] transform-[translateX(var(--drawer-swipe-movement-x))] touch-auto overflow-y-auto overscroll-contain bg-gray-50 p-4 pr-18 text-gray-900 shadow-xl outline-1 outline-gray-200 transition-[transform,box-shadow] duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--bleed:3rem] data-ending-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-ending-style:shadow-none data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-starting-style:shadow-none data-swiping:select-none supports-[-webkit-touch-callout:none]:mr-0 supports-[-webkit-touch-callout:none]:w-80 supports-[-webkit-touch-callout:none]:max-w-[calc(100vw-20px)] supports-[-webkit-touch-callout:none]:rounded-lg supports-[-webkit-touch-callout:none]:pr-6 supports-[-webkit-touch-callout:none]:[--bleed:0px]"
        >
          <Drawer.Content class="mx-auto w-full max-w-lg">
            <Drawer.Title class="-mt-1.5 mb-1 text-base font-semibold">Transcript</Drawer.Title>
            <Drawer.Description class="mb-4 text-sm text-gray-600">
              Focus is not trapped and outside clicks are ignored. Use the close button or swipe to
              dismiss.
            </Drawer.Description>
            <div class="flex justify-end gap-4">
              <Drawer.Close
                class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
              >
                Close
              </Drawer.Close>
            </div>
          </Drawer.Content>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  </Drawer.Root>
</template>

Mobile navigation

Build a full-screen mobile navigation sheet from Drawer parts, with flick-to-dismiss.

<script setup lang="ts">
import { Drawer } from '@shardsui/vue/drawer'
import { ScrollArea } from '@shardsui/vue/scroll-area'

const PRIMARY_LINKS = [
  { href: '/', label: 'Home' },
  { href: '/products', label: 'Products' },
  { href: '/activity', label: 'Activity' }
]

const TOPICS = [
  'Design Systems',
  'Typography',
  'Color',
  'Layout',
  'Responsive',
  'Accessibility',
  'Motion',
  'Tokens',
  'Spacing',
  'Grid',
  'Theming',
  'Dark Mode',
  'Iconography',
  'Components',
  'Variants',
  'Composition',
  'Primitives',
  'Reactivity',
  'State',
  'Interaction',
  'Prototyping',
  'Performance',
  'Bundle Size',
  'Testing',
  'Documentation',
  'Handoff',
  'Versioning',
  'Changelogs',
  'Governance',
  'Contribution'
]
</script>

<template>
  <Drawer.Root>
    <Drawer.Trigger
      class="m-0 box-border flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm/6 font-normal text-gray-900 outline-hidden select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Open mobile menu
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Backdrop
        class="data-ending-style:backdrop-blur-0 data-starting-style:backdrop-blur-0 fixed inset-0 min-h-dvh bg-black/10 opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] backdrop-blur-xs transition-[backdrop-filter,opacity] duration-600 ease-(--ease-out-fast) [--backdrop-opacity:1] data-ending-style:opacity-0 data-ending-style:duration-350 data-ending-style:ease-[cubic-bezier(0.375,0.015,0.545,0.455)] data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Drawer.Viewport class="group fixed inset-0">
        <ScrollArea.Root
          class="box-border h-full overscroll-contain transition-[transform,translate] duration-600 ease-[cubic-bezier(0.45,1.005,0,1.005)] group-data-ending-style:pointer-events-none group-data-starting-style:translate-y-[100dvh]"
        >
          <ScrollArea.Viewport class="box-border h-full touch-auto overscroll-contain">
            <ScrollArea.Content class="flex min-h-full items-end justify-center pt-8 md:p-16">
              <Drawer.Popup
                class="group box-border w-full max-w-2xl transform-[translateY(var(--drawer-swipe-movement-y))] outline-hidden transition-transform duration-600 ease-[cubic-bezier(0.45,1.005,0,1.005)] data-ending-style:transform-[translateY(calc(max(100dvh,100%)+2px))] data-ending-style:duration-350 data-ending-style:ease-[cubic-bezier(0.375,0.015,0.545,0.455)] data-swiping:select-none"
              >
                <nav
                  aria-label="Navigation"
                  class="relative flex flex-col rounded-t-2xl bg-gray-50 px-6 pt-4 pb-6 text-gray-900 shadow-2xl outline-1 outline-gray-200 transition-shadow duration-350 ease-[cubic-bezier(0.375,0.015,0.545,0.455)] group-data-ending-style:shadow-none md:rounded-xl"
                >
                  <div class="mb-3 grid grid-cols-[1fr_auto_1fr] items-center">
                    <div aria-hidden="true" class="size-8"></div>
                    <div class="h-1 w-12 justify-self-center rounded-full bg-gray-300"></div>
                    <Drawer.Close
                      aria-label="Close menu"
                      class="flex size-8 items-center justify-center justify-self-end rounded-full border border-gray-200 bg-gray-50 text-gray-900 hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                    >
                      <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
                        <path
                          d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
                          stroke="currentColor"
                          stroke-width="1.5"
                          stroke-linecap="round"
                        />
                      </svg>
                    </Drawer.Close>
                  </div>

                  <Drawer.Content class="w-full">
                    <Drawer.Title class="m-0 mb-1 text-base/7 font-semibold tracking-normal">
                      Menu
                    </Drawer.Title>
                    <Drawer.Description class="m-0 mb-5 text-sm/6 text-gray-600">
                      Scroll the list. Flick down from the top to dismiss.
                    </Drawer.Description>

                    <div class="pb-8">
                      <ul class="m-0 grid list-none gap-1 p-0">
                        <li v-for="item in PRIMARY_LINKS" :key="item.label" class="flex">
                          <a
                            class="w-full rounded-xl bg-gray-100 px-4 py-3 text-gray-900 no-underline focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950"
                            :href="item.href"
                          >
                            {{ item.label }}
                          </a>
                        </li>
                      </ul>

                      <ul aria-label="Topics" class="m-0 mt-6 grid list-none gap-1 p-0">
                        <li v-for="topic in TOPICS" :key="topic" class="flex">
                          <a
                            class="w-full rounded-xl bg-gray-100 px-4 py-3 text-gray-900 no-underline focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950"
                            href="/products"
                          >
                            {{ topic }}
                          </a>
                        </li>
                      </ul>
                    </div>
                  </Drawer.Content>
                </nav>
              </Drawer.Popup>
            </ScrollArea.Content>
          </ScrollArea.Viewport>
          <ScrollArea.Scrollbar
            class="pointer-events-none absolute m-1.5 flex w-1 justify-center rounded-2xl opacity-0 transition-opacity duration-250 hover:pointer-events-auto hover:opacity-100 hover:delay-0 hover:duration-75 data-ending-style:opacity-0 data-ending-style:duration-250 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:delay-0 data-scrolling:duration-75 md:w-1.75"
          >
            <ScrollArea.Thumb
              class="w-full rounded-[inherit] bg-gray-500 before:absolute before:top-1/2 before:left-1/2 before:size-[calc(100%+1rem)] before:-translate-1/2 before:content-['']"
            />
          </ScrollArea.Scrollbar>
        </ScrollArea.Root>
      </Drawer.Viewport>
    </Drawer.Portal>
  </Drawer.Root>
</template>

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.

Swipe from the right edge to open the drawer.

<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { Drawer } from '@shardsui/vue/drawer'

const portalContainer = useTemplateRef<HTMLDivElement>('portalContainer')
</script>

<template>
  <div
    ref="portalContainer"
    class="relative min-h-80 w-full overflow-hidden border border-gray-200 bg-gray-50 text-gray-900"
  >
    <Drawer.Root swipe-direction="right" :modal="false">
      <Drawer.SwipeArea
        class="absolute inset-y-0 right-0 z-10 box-border w-10 border-l-2 border-dashed border-blue-800 bg-blue-800/10"
      >
        <span
          class="pointer-events-none absolute top-1/2 right-0 mr-2 origin-center -translate-y-1/2 -rotate-90 text-xs font-semibold tracking-widest whitespace-nowrap text-blue-800 uppercase"
        >
          Swipe here
        </span>
      </Drawer.SwipeArea>
      <div class="flex min-h-80 items-center justify-center px-4">
        <p class="pr-12 text-center text-sm text-gray-600">
          Swipe from the right edge to open the drawer.
        </p>
      </div>
      <Drawer.Portal :container="portalContainer">
        <Drawer.Backdrop
          class="absolute inset-0 bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.2] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0"
        />
        <Drawer.Viewport
          class="absolute inset-0 z-20 flex items-stretch justify-end p-(--viewport-padding) [--viewport-padding:0px] supports-[-webkit-touch-callout:none]:[--viewport-padding:0.625rem]"
        >
          <Drawer.Popup
            class="-mr-12 h-full w-92 max-w-[calc(100vw-3rem+3rem)] transform-[translateX(var(--drawer-swipe-movement-x))] touch-auto overflow-y-auto overscroll-contain bg-gray-50 p-4 pr-18 text-gray-900 outline-1 outline-gray-200 transition-transform duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--bleed:3rem] data-ending-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-swiping:select-none supports-[-webkit-touch-callout:none]:mr-0 supports-[-webkit-touch-callout:none]:w-80 supports-[-webkit-touch-callout:none]:max-w-[calc(100vw-20px)] supports-[-webkit-touch-callout:none]:rounded-lg supports-[-webkit-touch-callout:none]:pr-6 supports-[-webkit-touch-callout:none]:[--bleed:0px]"
          >
            <Drawer.Content class="mx-auto w-full max-w-lg">
              <Drawer.Title class="-mt-1.5 mb-1 text-base font-semibold">Notes</Drawer.Title>
              <Drawer.Description class="mb-4 text-sm text-gray-600">
                Swipe in from the edge to jot something down.
              </Drawer.Description>
              <div class="flex justify-end gap-4">
                <Drawer.Close
                  class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                >
                  Close
                </Drawer.Close>
              </div>
            </Drawer.Content>
          </Drawer.Popup>
        </Drawer.Viewport>
      </Drawer.Portal>
    </Drawer.Root>
  </div>
</template>

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>
<script setup lang="ts">
import { shallowRef, useId } from 'vue'
import { AlertDialog } from '@shardsui/vue/alert-dialog'
import { Drawer } from '@shardsui/vue/drawer'

const drawerOpen = shallowRef(false)
const confirmationOpen = shallowRef(false)
const feedback = shallowRef('')
const titleId = useId()

function setDrawerOpen(open: boolean) {
  // Veto the close by not writing it back; prompt instead.
  if (!open && feedback.value) {
    confirmationOpen.value = true
    return
  }
  drawerOpen.value = open
}

function submitFeedback(event: Event) {
  event.preventDefault()
  feedback.value = ''
  drawerOpen.value = false
}

function discardFeedback() {
  confirmationOpen.value = false
  feedback.value = ''
  drawerOpen.value = false
}
</script>

<template>
  <Drawer.Root swipe-direction="right" :open="drawerOpen" @update:open="setDrawerOpen">
    <Drawer.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
    >
      Leave feedback
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.2] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Drawer.Viewport
        class="fixed inset-0 flex items-stretch justify-end p-(--viewport-padding) [--viewport-padding:0px] supports-[-webkit-touch-callout:none]:[--viewport-padding:0.625rem]"
      >
        <Drawer.Popup
          class="relative -mr-12 h-full w-92 max-w-[calc(100vw-3rem+3rem)] transform-[translateX(var(--drawer-swipe-movement-x))] touch-auto overflow-y-auto overscroll-contain bg-gray-50 p-4 pr-18 text-gray-900 outline-1 outline-gray-200 transition-transform duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--bleed:3rem] after:pointer-events-none after:absolute after:inset-0 after:bg-black/5 after:opacity-0 after:transition-opacity after:duration-100 after:ease-out data-ending-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateX(calc(100%-var(--bleed)+var(--viewport-padding)+2px))] data-swiping:select-none supports-[-webkit-touch-callout:none]:mr-0 supports-[-webkit-touch-callout:none]:w-80 supports-[-webkit-touch-callout:none]:max-w-[calc(100vw-20px)] supports-[-webkit-touch-callout:none]:rounded-lg supports-[-webkit-touch-callout:none]:pr-6 supports-[-webkit-touch-callout:none]:[--bleed:0px]"
          :class="confirmationOpen ? 'after:opacity-100' : ''"
        >
          <Drawer.Content class="mx-auto flex w-full max-w-lg flex-col gap-4">
            <Drawer.Title :id="titleId" class="-mt-1.5 text-base font-semibold"
              >Feedback</Drawer.Title
            >
            <form class="flex flex-col gap-4" @submit="submitFeedback">
              <textarea
                :aria-labelledby="titleId"
                required
                class="min-h-32 w-full rounded-md border border-gray-200 bg-gray-50 p-2 text-sm font-normal text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950 any-pointer-coarse:text-base"
                v-model="feedback"
                placeholder="Share your thoughts…"
              ></textarea>
              <div class="flex justify-end gap-4">
                <Drawer.Close
                  class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                >
                  Cancel
                </Drawer.Close>
                <button
                  type="submit"
                  class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
                >
                  Send
                </button>
              </div>
            </form>
          </Drawer.Content>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>

    <AlertDialog.Root v-model:open="confirmationOpen">
      <AlertDialog.Portal>
        <AlertDialog.Popup
          class="fixed top-1/2 left-1/2 -mt-8 w-96 max-w-[calc(100vw-3rem)] -translate-1/2 rounded-lg bg-gray-50 p-4 text-gray-900 outline-1 outline-gray-200 transition-[scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <AlertDialog.Title class="-mt-1.5 mb-1 text-base font-semibold"
            >Discard feedback?</AlertDialog.Title
          >
          <AlertDialog.Description class="mb-4 text-sm text-gray-600">
            Your message will be lost.
          </AlertDialog.Description>
          <div class="flex items-center justify-end gap-4">
            <AlertDialog.Close
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
            >
              Keep editing
            </AlertDialog.Close>
            <button
              type="button"
              class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
              @click="discardFeedback"
            >
              Discard
            </button>
          </div>
        </AlertDialog.Popup>
      </AlertDialog.Portal>
    </AlertDialog.Root>
  </Drawer.Root>
</template>

Action sheet with separate destructive action

An action sheet pairing a grouped list of actions with a separate, destructive action button.

<script setup lang="ts">
import { shallowRef } from 'vue'
import { Drawer } from '@shardsui/vue/drawer'

const ACTIONS = ['Rename', 'Duplicate', 'Open', 'Copy link']

const open = shallowRef(false)
</script>

<template>
  <Drawer.Root v-model:open="open">
    <Drawer.Trigger
      class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950"
    >
      Actions
    </Drawer.Trigger>
    <Drawer.Portal>
      <Drawer.Backdrop
        class="fixed inset-0 min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] [--backdrop-opacity:0.4] data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Drawer.Viewport class="fixed inset-0 flex items-end justify-center">
        <Drawer.Popup
          class="pointer-events-none box-border flex w-full max-w-80 transform-[translateY(var(--drawer-swipe-movement-y))] flex-col gap-3 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0))] outline-hidden transition-transform duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] focus-visible:outline-hidden data-ending-style:transform-[translateY(calc(100%+1rem+2px))] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*0.4s)] data-starting-style:transform-[translateY(calc(100%+1rem+2px))] data-swiping:select-none"
        >
          <Drawer.Content
            class="pointer-events-auto overflow-hidden rounded-2xl bg-gray-50 text-gray-900 outline-1 outline-gray-200"
          >
            <Drawer.Title class="sr-only">Actions</Drawer.Title>
            <Drawer.Description class="sr-only">Choose an action.</Drawer.Description>

            <ul class="m-0 list-none divide-y divide-gray-200 p-0" aria-label="Actions">
              <li v-for="(action, index) in ACTIONS" :key="action">
                <Drawer.Close v-if="index === 0" class="sr-only">Close action sheet</Drawer.Close>
                <button
                  type="button"
                  class="block w-full border-0 bg-transparent px-5 py-3 text-center text-sm text-gray-900 select-none hover:bg-gray-100 focus-visible:bg-gray-100 focus-visible:outline-hidden"
                  @click="open = false"
                >
                  {{ action }}
                </button>
              </li>
            </ul>
          </Drawer.Content>
          <div
            class="pointer-events-auto overflow-hidden rounded-2xl bg-gray-50 outline-1 outline-gray-200"
          >
            <button
              type="button"
              class="block w-full border-0 bg-transparent px-5 py-3 text-center text-sm text-red-800 select-none hover:bg-gray-100 focus-visible:bg-gray-100 focus-visible:outline-hidden"
              @click="open = false"
            >
              Delete
            </button>
          </div>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  </Drawer.Root>
</template>

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.

PropTypeDefault

IndentBackground

A background layer placed before <Drawer.Indent>, shown behind the app while a drawer indents it. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-activePresent when any drawer within the nearest <Drawer.Provider> is open.
data-inactivePresent when no drawer is open.

Indent

A wrapper element intended to contain your app's main UI. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-activePresent when any drawer in the provider is open.
data-inactivePresent when no drawer is open.
CSS VariableDescription
--drawer-swipe-progress0–1, swipe progress of the active drawer.
--drawer-heightHeight of the frontmost open drawer.

Root

Groups all parts of the drawer. Doesn't render its own HTML element.

PropTypeDefault

Trigger

A button that opens the drawer. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent while the drawer is open from this trigger.
data-disabledPresent when the trigger is disabled.

SwipeArea

An invisible area that listens for swipe gestures to open the drawer. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-swipingPresent when the drawer is being swiped.
data-swipe-directionThe direction of the swipe ('up' | 'down' | 'left' | 'right').
data-disabledPresent when the swipe area is disabled.

Portal

A portal that moves the popup out to <body>, clear of ancestor clipping and stacking. Renders a <div> element.

PropTypeDefault

Backdrop

An overlay displayed beneath the popup. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-nestedPresent when the drawer is nested within another dialog or drawer.
data-nested-dialog-openPresent when the drawer has other open dialogs nested within it.
data-starting-stylePresent when the backdrop is animating in.
data-ending-stylePresent when the backdrop is animating out.
data-swipingPresent while the drawer is being swiped.
data-swipe-dismissPresent when the drawer is dismissed by swiping.
CSS VariableDescription
--drawer-swipe-progress0–1, fades the backdrop with swipe progress.
--drawer-heightThe height of the frontmost open drawer, while swiping.
--drawer-swipe-strengthAlways 1 on the backdrop, so rules shared with the popup resolve here as well.

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.

PropTypeDefault
AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-starting-stylePresent when the drawer is animating in.
data-ending-stylePresent when the drawer is animating out.
data-nestedPresent when the drawer is nested within another drawer.

Popup

A container for the drawer contents. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the drawer is open.
data-closedPresent when the drawer is closed.
data-starting-stylePresent when the drawer is animating in.
data-ending-stylePresent when the drawer is animating out.
data-expandedPresent when the active snap point is the full-height (1) state.
data-nestedPresent when the drawer is nested within another dialog or drawer.
data-nested-drawer-openPresent when a nested drawer is open.
data-nested-drawer-swipingPresent when a nested drawer is being swiped.
data-swipe-dismissPresent when the drawer is dismissed by swiping.
data-swipe-directionThe direction of the swipe ('up' | 'down' | 'left' | 'right').
data-swipingPresent when the drawer is being swiped.
CSS VariableDescription
--nested-drawersThe number of nested drawers that are currently open.
--drawer-heightThe height of the drawer popup.
--drawer-frontmost-heightThe height of the frontmost open drawer in the current nested drawer stack.
--drawer-swipe-movement-xThe swipe movement on the X axis.
--drawer-swipe-movement-yThe swipe movement on the Y axis.
--drawer-snap-point-offsetThe snap point offset used for translating the drawer.
--drawer-swipe-strengthScalar (0.1–1) used to scale the swipe release transition duration in CSS.
--drawer-swipe-progress0–1, swipe progress of the nested drawer stacked on top of this one.

Content

An inner container whose children can be selected with a mouse or pen without starting a swipe. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-drawer-contentAlways present; marks the content box.

Title

A heading that labels the drawer. Renders an <h2> element.

PropTypeDefault

Description

A paragraph with additional information about the drawer. Renders a <p> element.

PropTypeDefault

Close

A button that closes the drawer. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-disabledPresent when the button is disabled.

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>()
MemberType