ShardsUI is in beta. APIs may change before 1.0.

Drawer

A panel from a screen edge.

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

<Drawer.Root swipeDirection="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>

Anatomy

<script>
  import { Drawer } from '@shardsui/svelte/drawer'
</script>

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

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 — use Dialog instead.

Examples

State

By default, Drawer manages its own state.

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

Drive open with open / onOpenChange, or bind:open.

<script>
  let open = $state(false)
</script>

<Drawer.Root {open} onOpenChange={(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>

Position

Positioning is handled by your styles. swipeDirection defaults to "down" for bottom sheets. Use "up", "left", or "right" for other drawer positions.

<Drawer.Root swipeDirection="left">...</Drawer.Root>
<script lang="ts">
  import { Drawer } from '@shardsui/svelte/drawer'
</script>

<Drawer.Root swipeDirection="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>

Nested drawers

Use the [data-nested-drawer-open] selector and the --nested-drawers CSS variable to style drawers when a nested drawer is open. This demo stacks nested drawers with a constant peek: the frontmost drawer stays anchored to the bottom while the ones behind are scaled down and lifted, using the --drawer-height and --drawer-frontmost-height CSS variables to handle varying heights.

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

<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">
                                      {#each ['Editor', 'Reviewer', 'Viewer'] as role (role)}
                                        <label
                                          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>
                                      {/each}
                                    </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>

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>
  const snapPoints = ['148px', 1]
  let snapPoint = $state(snapPoints[0])
</script>

<Drawer.Root {snapPoints} bind:snapPoint>...</Drawer.Root>

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

<Drawer.Root {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">
              {#each rows as row (row)}
                <li class="h-12 rounded-xl bg-gray-100"></li>
              {/each}
            </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>

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

  let portalContainer = $state<HTMLDivElement | null>(null)
</script>

<Drawer.Provider>
  <div bind:this={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>

Non-modal

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

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

<Drawer.Root swipeDirection="right" modal={false} disablePointerDismissal>
  <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>

Mobile navigation

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

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

<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"
                  >
                    {@render crossIcon()}
                  </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">
                      {#each PRIMARY_LINKS as item (item.label)}
                        <li 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>
                      {/each}
                    </ul>

                    <ul aria-label="Topics" class="m-0 mt-6 grid list-none gap-1 p-0">
                      {#each TOPICS as topic (topic)}
                        <li 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>
                      {/each}
                    </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>

{#snippet crossIcon()}
  <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>
{/snippet}

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

  let portalContainer = $state<HTMLDivElement | null>(null)
</script>

<div
  bind:this={portalContainer}
  class="relative min-h-80 w-full overflow-hidden border border-gray-200 bg-gray-50 text-gray-900"
>
  <Drawer.Root swipeDirection="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>

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 with a function bindingbind:open={() => open, (next) => …}. When a close is requested the setter runs; if you don't commit the new value, the getter keeps returning the old one 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.

<Drawer.Root
  bind:open={
    () => open,
    (next) => {
      if (!next && hasUnsavedChanges) return // veto: don't commit, drawer stays open
      open = next
    }
  }
>
<script lang="ts">
  import { AlertDialog } from '@shardsui/svelte/alert-dialog'
  import { Drawer } from '@shardsui/svelte/drawer'

  let drawerOpen = $state(false)
  let confirmationOpen = $state(false)
  let feedback = $state('')
  const titleId = $props.id()
</script>

<Drawer.Root
  swipeDirection="right"
  bind:open={
    () => drawerOpen,
    (open) => {
      // Veto the close by not committing; prompt instead.
      if (!open && feedback) {
        confirmationOpen = true
        return
      }
      drawerOpen = 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 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] {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"
            onsubmit={(event) => {
              event.preventDefault()
              feedback = ''
              drawerOpen = false
            }}
          >
            <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"
              placeholder="Share your thoughts…"
              bind:value={feedback}></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 bind: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"
            onclick={() => {
              confirmationOpen = false
              feedback = ''
              drawerOpen = false
            }}
          >
            Discard
          </button>
        </div>
      </AlertDialog.Popup>
    </AlertDialog.Portal>
  </AlertDialog.Root>
</Drawer.Root>

Action sheet with separate destructive action

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

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

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

  let open = $state(false)
</script>

<Drawer.Root bind: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">
            {#each ACTIONS as action, index (action)}
              <li>
                {#if index === 0}
                  <Drawer.Close class="sr-only">Close action sheet</Drawer.Close>
                {/if}
                <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"
                  onclick={() => (open = false)}
                >
                  {action}
                </button>
              </li>
            {/each}
          </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"
            onclick={() => (open = false)}
          >
            Delete
          </button>
        </div>
      </Drawer.Popup>
    </Drawer.Viewport>
  </Drawer.Portal>
</Drawer.Root>

Detached triggers

For a simple one-off, keep <Drawer.Trigger> inside <Drawer.Root>. When the content can't sit beside its trigger, detach it: create a handle with new Drawer.Handle() and pass it to both <Drawer.Trigger handle={...}> and <Drawer.Root handle={...}>. They stay linked no matter where each lives in the tree.

<script>
  const demoDrawer = new Drawer.Handle()
</script>

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

To show different content depending on which trigger opened the drawer, pass a payload to each <Drawer.Trigger> and read it through the children snippet on <Drawer.Root>. Give new Drawer.Handle() a type argument to type the payload:

<script>
  const demoDrawer = new Drawer.Handle<{ title: string }>()
</script>

<Drawer.Trigger handle={demoDrawer} payload={{ title: 'Profile' }}>Profile</Drawer.Trigger>

<Drawer.Trigger handle={demoDrawer} payload={{ title: 'Settings' }}>Settings</Drawer.Trigger>

<Drawer.Root handle={demoDrawer}>
  {#snippet children({ payload })}
    <Drawer.Portal>
      <Drawer.Viewport>
        <Drawer.Popup>
          <Drawer.Content>
            {#if payload !== undefined}
              <Drawer.Title>{payload.title}</Drawer.Title>
            {/if}
            <Drawer.Close>Close</Drawer.Close>
          </Drawer.Content>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  {/snippet}
</Drawer.Root>

Stacking and animations

Use CSS transitions or animations to animate drawer opening, closing, swipe interactions, and nested stacking. data-starting-style is applied when a drawer starts to open, data-ending-style when it starts to close.

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;
}

The data-nested-drawer-open attribute marks drawers behind the frontmost drawer. Use it 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 = new Drawer.Handle<Payload>()
MemberType