8.0k New

Drawer

A drawer component for Svelte.

Docs

Epicenter

Local-first, open source apps

Special Sponsor
<script lang="ts">
  import { toast } from "svelte-sonner";
  import * as Drawer from "$lib/components/ui/drawer/index.js";
  import * as Field from "$lib/components/ui/field/index.js";
  import * as RadioGroup from "$lib/components/ui/radio-group/index.js";
  import { IsMobile } from "$lib/components/hooks/is-mobile.svelte.js";
  import { Badge } from "$lib/components/ui/badge/index.js";
  import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
 
  const deliveryTimes = [
    {
      value: "asap",
      id: "delivery-asap",
      label: "Standard delivery",
      description: "25–35 min · Driver assigned now",
      badge: "Fastest"
    },
    {
      value: "5-00",
      id: "delivery-5-00",
      label: "5:00 PM – 5:15 PM",
      description: "Prep starts at 4:45 PM"
    },
    {
      value: "5-30",
      id: "delivery-5-30",
      label: "5:30 PM – 5:45 PM",
      description: "Good if you're heading home"
    },
    {
      value: "6-00",
      id: "delivery-6-00",
      label: "6:00 PM – 6:15 PM",
      description: "Most popular · High demand"
    },
    {
      value: "6-30",
      id: "delivery-6-30",
      label: "6:30 PM – 6:45 PM",
      description: "Last slot before kitchen closes"
    }
  ];
 
  let open = $state(false);
  let deliveryTime = $state("asap");
  const isMobile = new IsMobile();
 
  function handleConfirm() {
    const selected = deliveryTimes.find((time) => time.value === deliveryTime);
 
    if (!selected) {
      return;
    }
 
    open = false;
    toast("Delivery time confirmed", {
      description: selected.label
    });
  }
</script>
 
<Drawer.Root bind:open direction={isMobile.current ? "bottom" : "right"}>
  <Drawer.Trigger class={buttonVariants({ variant: "secondary" })}
    >Open Drawer</Drawer.Trigger
  >
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>Pick a delivery time</Drawer.Title>
      <Drawer.Description
        >We&apos;ll prepare your order as soon as possible.</Drawer.Description
      >
    </Drawer.Header>
    <div class="flex-1 overflow-y-auto p-4">
      <RadioGroup.Root bind:value={deliveryTime} class="gap-2">
        {#each deliveryTimes as time (time.value)}
          <Field.Label for={time.id}>
            <Field.Field orientation="horizontal">
              <Field.Content>
                <Field.Title class="flex items-center gap-2">
                  {time.label}
                  {#if time.badge}
                    <Badge variant="secondary">{time.badge}</Badge>
                  {/if}
                </Field.Title>
                <Field.Description>{time.description}</Field.Description>
              </Field.Content>
              <RadioGroup.Item value={time.value} id={time.id} />
            </Field.Field>
          </Field.Label>
        {/each}
      </RadioGroup.Root>
    </div>
    <Drawer.Footer>
      <Button onclick={handleConfirm} class="h-[34px]"
        >Confirm Delivery Time</Button
      >
      <Drawer.Close class={buttonVariants({ variant: "outline" })}
        >Cancel</Drawer.Close
      >
    </Drawer.Footer>
  </Drawer.Content>
</Drawer.Root>

About

Drawer is built on top of Vaul Svelte, which is a Svelte port of Vaul by Emil Kowalski.

Installation

pnpm dlx shadcn-svelte@latest add drawer

Usage

<script lang="ts">
  import * as Drawer from "$lib/components/ui/drawer/index.js";
</script>
<Drawer.Root>
  <Drawer.Trigger>Open</Drawer.Trigger>
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>Are you absolutely sure?</Drawer.Title>
      <Drawer.Description>This action cannot be undone.</Drawer.Description>
    </Drawer.Header>
    <Drawer.Footer>
      <Button>Submit</Button>
      <Drawer.Close>Cancel</Drawer.Close>
    </Drawer.Footer>
  </Drawer.Content>
</Drawer.Root>

Position

Use the direction prop to set the side of the drawer.

Available options are top, right, bottom, and left.

<script lang="ts">
  import * as Drawer from "$lib/components/ui/drawer/index.js";
  import { buttonVariants } from "$lib/components/ui/button/index.js";
</script>
 
<Drawer.Root direction="left">
  <Drawer.Trigger class={buttonVariants({ variant: "secondary" })}
    >Open Left Drawer</Drawer.Trigger
  >
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>Move Goal</Drawer.Title>
      <Drawer.Description>Set your daily activity goal.</Drawer.Description>
    </Drawer.Header>
    <div class="flex-1 p-4">
      <div class="size-full rounded-2xl bg-muted"></div>
    </div>
    <Drawer.Footer>
      <Drawer.Close class={buttonVariants()}>Close</Drawer.Close>
    </Drawer.Footer>
  </Drawer.Content>
</Drawer.Root>

Nested

Open drawers from inside another drawer using <Drawer.NestedRoot />. Parent drawers stay mounted and stack behind the frontmost drawer.

<script lang="ts">
  import * as Drawer from "$lib/components/ui/drawer/index.js";
  import { IsMobile } from "$lib/components/hooks/is-mobile.svelte.js";
  import { buttonVariants } from "$lib/components/ui/button/index.js";
 
  const isMobile = new IsMobile();
  const direction = $derived(isMobile.current ? "bottom" : "right");
</script>
 
{#snippet placeholder()}
  <div class="flex-1 p-4">
    <div
      class="bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:aspect-video group-data-[vaul-drawer-direction=bottom]/drawer-content:w-full group-data-[vaul-drawer-direction=right]/drawer-content:size-full"
    ></div>
  </div>
{/snippet}
 
<Drawer.Root {direction}>
  <Drawer.Trigger class={buttonVariants({ variant: "secondary" })}
    >Open Drawer</Drawer.Trigger
  >
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>Drawer</Drawer.Title>
      <Drawer.Description
        >Open another drawer from the same direction.</Drawer.Description
      >
    </Drawer.Header>
    {@render placeholder()}
    <Drawer.Footer>
      <Drawer.NestedRoot {direction}>
        <Drawer.Trigger class={buttonVariants({ variant: "outline" })}>
          Open Nested Drawer
        </Drawer.Trigger>
        <Drawer.Content>
          <Drawer.Header>
            <Drawer.Title>Nested Drawer</Drawer.Title>
            <Drawer.Description>
              The parent drawer stays mounted behind this one.
            </Drawer.Description>
          </Drawer.Header>
          {@render placeholder()}
          <Drawer.Footer>
            <Drawer.NestedRoot {direction}>
              <Drawer.Trigger class={buttonVariants({ variant: "outline" })}>
                Open Third Drawer
              </Drawer.Trigger>
              <Drawer.Content>
                <Drawer.Header>
                  <Drawer.Title>Third Drawer</Drawer.Title>
                  <Drawer.Description
                    >Two drawers are stacked behind this one.</Drawer.Description
                  >
                </Drawer.Header>
                {@render placeholder()}
                <Drawer.Footer>
                  <Drawer.NestedRoot {direction}>
                    <Drawer.Trigger
                      class={buttonVariants({ variant: "outline" })}
                    >
                      Open Fourth Drawer
                    </Drawer.Trigger>
                    <Drawer.Content>
                      <Drawer.Header>
                        <Drawer.Title>Fourth Drawer</Drawer.Title>
                        <Drawer.Description>
                          This is the frontmost drawer in the stack.
                        </Drawer.Description>
                      </Drawer.Header>
                      {@render placeholder()}
                      <Drawer.Footer>
                        <Drawer.Close
                          class={buttonVariants({ variant: "outline" })}
                        >
                          Close
                        </Drawer.Close>
                      </Drawer.Footer>
                    </Drawer.Content>
                  </Drawer.NestedRoot>
                  <Drawer.Close class={buttonVariants({ variant: "outline" })}
                    >Close</Drawer.Close
                  >
                </Drawer.Footer>
              </Drawer.Content>
            </Drawer.NestedRoot>
            <Drawer.Close class={buttonVariants({ variant: "outline" })}
              >Close</Drawer.Close
            >
          </Drawer.Footer>
        </Drawer.Content>
      </Drawer.NestedRoot>
      <Drawer.Close class={buttonVariants({ variant: "outline" })}
        >Close</Drawer.Close
      >
    </Drawer.Footer>
  </Drawer.Content>
</Drawer.Root>

Non Modal

Set modal={false} to allow interaction with the rest of the page while the drawer is open. Clicking outside a non-modal drawer does not close it.

<script lang="ts">
  import * as Drawer from "$lib/components/ui/drawer/index.js";
  import { buttonVariants } from "$lib/components/ui/button/index.js";
</script>
 
<Drawer.Root modal={false} direction="right">
  <Drawer.Trigger class={buttonVariants({ variant: "outline" })}
    >Non Modal</Drawer.Trigger
  >
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>Non Modal Drawer</Drawer.Title>
    </Drawer.Header>
    <div class="flex-1 p-4">
      <div class="size-full rounded-2xl bg-muted"></div>
    </div>
    <Drawer.Footer>
      <Drawer.Close class={buttonVariants()}>Close</Drawer.Close>
    </Drawer.Footer>
  </Drawer.Content>
</Drawer.Root>

Snap Points

Use snapPoints to snap a drawer to preset heights. Numbers between 0 and 1 represent fractions of the viewport. String values support px units. Snap points apply to vertical drawers.

Track the active snap point with bind:activeSnapPoint.

<script lang="ts">
  import * as Drawer from "$lib/components/ui/drawer/index.js";
  import { buttonVariants } from "$lib/components/ui/button/index.js";
 
  const SNAP_POINTS = ["496px", 1];
 
  let snapPoint = $state<string | number | null>(SNAP_POINTS[0]);
</script>
 
<Drawer.Root snapPoints={SNAP_POINTS} bind:activeSnapPoint={snapPoint}>
  <Drawer.Trigger class={buttonVariants({ variant: "outline" })}
    >Open Snap Drawer</Drawer.Trigger
  >
  <Drawer.Content
    class="h-full data-[vaul-drawer-direction=bottom]:max-h-[97%]"
  >
    <Drawer.Header>
      <Drawer.Title>Snap points</Drawer.Title>
      <Drawer.Description>
        Drag the drawer to snap between a compact peek and a near full-height
        view.
      </Drawer.Description>
    </Drawer.Header>
    <div class="p-4">
      <div class="h-80 w-full rounded-2xl bg-muted"></div>
    </div>
    <Drawer.Footer>
      <Drawer.Close class={buttonVariants()}>Close</Drawer.Close>
    </Drawer.Footer>
  </Drawer.Content>
</Drawer.Root>

Responsive

You can combine the Dialog and Drawer components to create a responsive dialog. This renders a Dialog component on desktop and a Drawer on mobile.

<script lang="ts">
  import { MediaQuery } from "svelte/reactivity";
  import * as Dialog from "$lib/components/ui/dialog/index.js";
  import * as Drawer from "$lib/components/ui/drawer/index.js";
  import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
  import { Input } from "$lib/components/ui/input/index.js";
  import { Label } from "$lib/components/ui/label/index.js";
 
  let open = $state(false);
  const isDesktop = new MediaQuery("(min-width: 768px)");
 
  const id = $props.id();
</script>
 
{#if isDesktop.current}
  <Dialog.Root bind:open>
    <Dialog.Trigger class={buttonVariants({ variant: "outline" })}
      >Edit Profile</Dialog.Trigger
    >
    <Dialog.Content class="sm:max-w-[425px]">
      <Dialog.Header>
        <Dialog.Title>Edit profile</Dialog.Title>
        <Dialog.Description>
          Make changes to your profile here. Click save when you're done.
        </Dialog.Description>
      </Dialog.Header>
      <form class="grid items-start gap-6">
        <div class="grid gap-3">
          <Label for="email-{id}">Email</Label>
          <Input type="email" id="email-{id}" value="shadcn@example.com" />
        </div>
        <div class="grid gap-3">
          <Label for="username-{id}">Username</Label>
          <Input id="username-{id}" value="@shadcn" />
        </div>
        <Button type="submit">Save changes</Button>
      </form>
    </Dialog.Content>
  </Dialog.Root>
{:else}
  <Drawer.Root bind:open>
    <Drawer.Trigger class={buttonVariants({ variant: "outline" })}
      >Edit Profile</Drawer.Trigger
    >
    <Drawer.Content>
      <Drawer.Header class="text-start">
        <Drawer.Title>Edit profile</Drawer.Title>
        <Drawer.Description>
          Make changes to your profile here. Click save when you're done.
        </Drawer.Description>
      </Drawer.Header>
      <form class="grid items-start gap-6 p-4">
        <div class="grid gap-3">
          <Label for="email-{id}">Email</Label>
          <Input type="email" id="email-{id}" value="shadcn@example.com" />
        </div>
        <div class="grid gap-3">
          <Label for="username-{id}">Username</Label>
          <Input id="username-{id}" value="@shadcn" />
        </div>
        <Button type="submit">Save changes</Button>
      </form>
    </Drawer.Content>
  </Drawer.Root>
{/if}