Drawer

A panel that slides in from an edge of the screen and can be swiped away.

Demo
Swipe Handle
Position
Custom Width and Height
Scrollable Content
Snap Points
Nested
Non Modal

Installation

Install the dependencies

pnpm add cn github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/drawer.

src/lib/components/ui/drawer/Drawer.svelte
<script lang="ts" generics="Payload = unknown">
  import { Drawer as Primitive, type DrawerRootProps } from '@sveltery/base/drawer';
  import { setDrawerContext } from './context.js';
  let { open = $bindable(), snapPoint = $bindable(), triggerId = $bindable(), modal = true, showSwipeHandle = false, snapPoints, swipeDirection = 'down', ...props }: DrawerRootProps<Payload> & { showSwipeHandle?: boolean } = $props();

  setDrawerContext({
    get hasSnapPoints() { return snapPoints != null && snapPoints.length > 0; },
    get modal() { return modal; },
    get showSwipeHandle() { return showSwipeHandle; },
    get swipeDirection() { return swipeDirection; },
  });
</script>
<Primitive.Root bind:open bind:snapPoint bind:triggerId {modal} {snapPoints} {swipeDirection} {...props} />
src/lib/components/ui/drawer/DrawerClose.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Drawer as Primitive } from '@sveltery/base/drawer';
  let { children, ...props }: ComponentProps<typeof Primitive.Close> = $props();
</script>
<Primitive.Close data-slot="drawer-close" {children} {...props} />
src/lib/components/ui/drawer/DrawerContent.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Drawer as Primitive } from '@sveltery/base/drawer';
  import { cn } from 'cn';
  import { useDrawer } from './context.js';
  import DrawerPortal from './DrawerPortal.svelte';
  import DrawerOverlay from './DrawerOverlay.svelte';
  import DrawerSwipeHandle from './DrawerSwipeHandle.svelte';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Popup> = $props();
  const drawer = useDrawer();
  const swipeAxis = $derived(drawer.swipeDirection === 'down' || drawer.swipeDirection === 'up' ? 'y' : 'x');
</script>
<DrawerPortal>
  {#if drawer.modal === true}
    <DrawerOverlay data-snap-points={drawer.hasSnapPoints ? '' : undefined} />
  {/if}
  <Primitive.Viewport data-slot="drawer-viewport" data-modal={drawer.modal} class="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto">
    <Primitive.Popup
      data-slot="drawer-popup"
      data-swipe-axis={swipeAxis}
      data-snap-points={drawer.hasSnapPoints ? '' : undefined}
      {...props}
      class={cn(
        'bg-popover text-popover-foreground text-sm data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords]',
        'data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95',
        'after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full',
        '[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]',
        '[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]',
        'data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]',
        'data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)',
        'data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row',
        'data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]',
        'data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]',
        'data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]',
        'data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]',
        classProp,
      )}
    >
      {#if drawer.showSwipeHandle}
        <DrawerSwipeHandle />
      {/if}
      <Primitive.Content data-slot="drawer-content" class="flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none">
        {@render children?.()}
      </Primitive.Content>
    </Primitive.Popup>
  </Primitive.Viewport>
</DrawerPortal>
src/lib/components/ui/drawer/DrawerDescription.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Drawer as Primitive } from '@sveltery/base/drawer';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Description> = $props();
</script>
<Primitive.Description data-slot="drawer-description" {children} {...props} class={cn('text-muted-foreground text-sm text-balance', classProp)} />
src/lib/components/ui/drawer/DrawerFooter.svelte
<script lang="ts">
  import type { HTMLAttributes } from 'svelte/elements';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="drawer-footer" {...props} class={cn('gap-2 p-4 pt-0 mt-auto flex shrink-0 flex-col', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/drawer/DrawerHeader.svelte
<script lang="ts">
  import type { HTMLAttributes } from 'svelte/elements';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="drawer-header" {...props} class={cn('gap-0.5 p-4 pb-0 md:gap-0.5 md:text-left flex shrink-0 flex-col group-data-[swipe-axis=y]/drawer-popup:text-center', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/drawer/DrawerOverlay.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Drawer as Primitive } from '@sveltery/base/drawer';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Backdrop> = $props();
</script>
<Primitive.Backdrop data-slot="drawer-overlay" {children} {...props} class={cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 min-h-dvh opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute', classProp)} />
src/lib/components/ui/drawer/DrawerPortal.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Drawer as Primitive } from '@sveltery/base/drawer';
  let { children, ...props }: ComponentProps<typeof Primitive.Portal> = $props();
</script>
<Primitive.Portal data-slot="drawer-portal" {...props}>
  {@render children?.()}
</Primitive.Portal>
src/lib/components/ui/drawer/DrawerSwipeHandle.svelte
<script lang="ts">
  import type { HTMLAttributes } from 'svelte/elements';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div data-slot="drawer-swipe-handle" aria-hidden="true" {...props} class={cn('after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=up]/drawer-popup:items-start group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:order-last active:cursor-grabbing', classProp)}>
  {@render children?.()}
</div>
src/lib/components/ui/drawer/DrawerTitle.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Drawer as Primitive } from '@sveltery/base/drawer';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Title> = $props();
</script>
<Primitive.Title data-slot="drawer-title" {children} {...props} class={cn('text-foreground text-base font-medium', classProp)} />
src/lib/components/ui/drawer/DrawerTrigger.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Drawer as Primitive } from '@sveltery/base/drawer';
  let { children, ...props }: ComponentProps<typeof Primitive.Trigger> = $props();
</script>
<Primitive.Trigger data-slot="drawer-trigger" {children} {...props} />
src/lib/components/ui/drawer/context.ts
import { createContext } from 'svelte';
import type { DrawerRootProps, DrawerSwipeDirection } from '@sveltery/base/drawer';

export type DrawerContextValue = {
  readonly hasSnapPoints: boolean;
  readonly modal: DrawerRootProps['modal'];
  readonly showSwipeHandle: boolean;
  readonly swipeDirection: DrawerSwipeDirection;
};

export const [useDrawer, setDrawerContext] = createContext<DrawerContextValue>();
src/lib/components/ui/drawer/index.ts
export { default as Drawer } from './Drawer.svelte';
export { default as DrawerTrigger } from './DrawerTrigger.svelte';
export { default as DrawerPortal } from './DrawerPortal.svelte';
export { default as DrawerClose } from './DrawerClose.svelte';
export { default as DrawerOverlay } from './DrawerOverlay.svelte';
export { default as DrawerSwipeHandle } from './DrawerSwipeHandle.svelte';
export { default as DrawerContent } from './DrawerContent.svelte';
export { default as DrawerHeader } from './DrawerHeader.svelte';
export { default as DrawerFooter } from './DrawerFooter.svelte';
export { default as DrawerTitle } from './DrawerTitle.svelte';
export { default as DrawerDescription } from './DrawerDescription.svelte';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '$lib/components/ui/drawer';
  let open = $state(false);
</script>
<Drawer bind:open>
  <DrawerTrigger>Open</DrawerTrigger>
  <DrawerContent>
    <DrawerHeader>
      <DrawerTitle>Are you absolutely sure?</DrawerTitle>
      <DrawerDescription>This action cannot be undone.</DrawerDescription>
    </DrawerHeader>
    <DrawerFooter>
      <DrawerClose>Cancel</DrawerClose>
    </DrawerFooter>
  </DrawerContent>
</Drawer>

The drawer opens from the bottom edge. Escape, a press outside, the Close button or a swipe toward the edge closes it.

Position

swipeDirection on Drawer sets the edge the drawer is attached to and the direction a swipe dismisses it in: up, right, down (default) or left. A drawer on the left or right edge is 75% of the viewport wide, and 24rem from the sm breakpoint. Pass a class such as data-[swipe-direction=right]:w-xs to DrawerContent to change the size.

Swipe handle

showSwipeHandle on Drawer adds a grab handle on the edge the drawer is swiped from.

Snap points

snapPoints lets the drawer rest at several heights. Numbers from 0 to 1 are fractions of the viewport height, larger numbers are pixels, and strings take px or rem units. Use bind:snapPoint to read or set the active snap point.

<Drawer snapPoints={['31rem', 1]} showSwipeHandle>…</Drawer>

Nested drawers

Put a Drawer inside a DrawerContent to stack drawers. The parent drawer scales back and stays mounted behind the nested one.

Non-modal

modal={false} leaves the page usable while the drawer is open and removes the overlay. Add disablePointerDismissal to keep the drawer open when the user presses outside it.

Trigger

DrawerTrigger and DrawerClose render a <button>. Pass class to style them, for example with buttonVariants. To use another element, pass a render snippet and spread its props onto that element.

API reference

Drawer is styled on top of the Sveltery Base Drawer primitive, which owns its swipe gestures, snap points, focus handling and accessibility. See the Base UI Drawer documentation for the behavior it ports.