<script lang="ts">
import MinusIcon from "@lucide/svelte/icons/minus";
import PlusIcon from "@lucide/svelte/icons/plus";
import * as Drawer from "$lib/components/ui/drawer/index.js";
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
import { BarChart } from "layerchart";
import { scaleBand } from "d3-scale";
import { cubicInOut } from "svelte/easing";
const data = [
{
goal: 400
},
{
goal: 300
},
{
goal: 200
},
{
goal: 300
},
{
goal: 200
},
{
goal: 278
},
{
goal: 189
},
{
goal: 239
},
{
goal: 300
},
{
goal: 200
},
{
goal: 278
},
{
goal: 189
},
{
goal: 349
}
];
let goal = $state(350);
function handleClick(adjustment: number) {
goal = Math.max(200, Math.min(400, goal + adjustment));
}
</script>
<Drawer.Root>
<Drawer.Trigger class={buttonVariants({ variant: "outline" })}
>Open Drawer</Drawer.Trigger
>
<Drawer.Content>
<div class="mx-auto w-full max-w-sm">
<Drawer.Header>
<Drawer.Title>Move Goal</Drawer.Title>
<Drawer.Description>Set your daily activity goal.</Drawer.Description>
</Drawer.Header>
<div class="p-4 pb-0">
<div class="flex items-center justify-center space-x-2">
<Button
variant="outline"
size="icon"
class="size-8 shrink-0 rounded-full"
onclick={() => handleClick(-10)}
disabled={goal <= 200}
>
<MinusIcon />
<span class="sr-only">Decrease</span>
</Button>
<div class="flex-1 text-center">
<div class="text-7xl font-bold tracking-tighter">
{goal}
</div>
<div class="text-muted-foreground text-[0.70rem] uppercase">
Calories/day
</div>
</div>
<Button
variant="outline"
size="icon"
class="size-8 shrink-0 rounded-full"
onclick={() => handleClick(10)}
disabled={goal >= 400}
>
<PlusIcon />
<span class="sr-only">Increase</span>
</Button>
</div>
<div class="mt-3 h-[120px]">
<div class="h-full w-full">
<BarChart
data={data.map((d, i) => ({ goal: d.goal, index: i }))}
y="goal"
x="index"
xScale={scaleBand().padding(0.25)}
axis={false}
tooltipContext={false}
props={{
bars: {
stroke: "none",
rounded: "all",
radius: 4,
motion: { type: "tween", duration: 500, easing: cubicInOut },
fill: "var(--color-foreground)",
fillOpacity: 0.9
},
highlight: { area: { fill: "none" } }
}}
/>
</div>
</div>
</div>
<Drawer.Footer>
<Button>Submit</Button>
<Drawer.Close class={buttonVariants({ variant: "outline" })}
>Cancel</Drawer.Close
>
</Drawer.Footer>
</div>
</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 Install vaul-svelte:
pnpm add vaul-svelte@next -D Copy and paste the following code into your project.
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import { cn } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.CloseProps = $props();
</script>
<DrawerPrimitive.Close
bind:ref
data-slot="drawer-close"
class={cn(
'outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/30',
className
)}
{...restProps}
/>
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import DrawerPortal from './drawer-portal.svelte';
import DrawerOverlay from './drawer-overlay.svelte';
import { cn } from '$UTILS$.js';
import type { ComponentProps } from 'svelte';
import type { WithoutChildrenOrChild } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: DrawerPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
} = $props();
</script>
<DrawerPortal {...portalProps}>
<DrawerOverlay />
<DrawerPrimitive.Content
bind:ref
data-slot="drawer-content"
class={cn(
'fixed z-50 flex h-auto flex-col border-[#222225] bg-[#09090b] text-sm text-zinc-50 shadow-none data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-none data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-none data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-none data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-none data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm group/drawer-content',
className
)}
{...restProps}
>
<div
data-slot="drawer-handle"
class="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-[#d0e891] group-data-[vaul-drawer-direction=bottom]/drawer-content:block"
></div>
{@render children?.()}
</DrawerPrimitive.Content>
</DrawerPortal>
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import { cn } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.DescriptionProps = $props();
</script>
<DrawerPrimitive.Description
bind:ref
data-slot="drawer-description"
class={cn('mt-0.5 text-sm leading-relaxed text-zinc-400', className)}
{...restProps}
/>
<script lang="ts">
import { cn, type WithElementRef } from '$UTILS$.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-footer"
class={cn('mt-auto flex flex-col gap-2 border-t border-[#222225] p-4', className)}
{...restProps}
>
{@render children?.()}
</div>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="drawer-header"
class={cn(
'flex flex-col gap-1 border-b border-[#222225] p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-2 md:text-left',
className
)}
{...restProps}
>
{@render children?.()}
</div>
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import { cn } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.OverlayProps = $props();
</script>
<DrawerPrimitive.Overlay
bind:ref
data-slot="drawer-overlay"
class={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-background/80 supports-backdrop-filter:backdrop-blur-[2px]',
className
)}
{...restProps}
/>
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
</script>
<DrawerPrimitive.Portal {...restProps} />
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import { cn } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.TitleProps = $props();
</script>
<DrawerPrimitive.Title
bind:ref
data-slot="drawer-title"
class={cn('font-mono text-sm font-semibold uppercase tracking-[0.08em] text-zinc-50', className)}
{...restProps}
/>
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
import { cn } from '$UTILS$.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DrawerPrimitive.TriggerProps = $props();
</script>
<DrawerPrimitive.Trigger
bind:ref
data-slot="drawer-trigger"
class={cn(
'outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/30',
className
)}
{...restProps}
/>
<script lang="ts">
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
...restProps
}: DrawerPrimitive.RootProps = $props();
</script>
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
import Root from './drawer.svelte';
import Content from './drawer-content.svelte';
import Description from './drawer-description.svelte';
import Overlay from './drawer-overlay.svelte';
import Footer from './drawer-footer.svelte';
import Header from './drawer-header.svelte';
import Title from './drawer-title.svelte';
import NestedRoot from './drawer-nested.svelte';
import Close from './drawer-close.svelte';
import Trigger from './drawer-trigger.svelte';
import Portal from './drawer-portal.svelte';
export {
Root,
NestedRoot,
Content,
Description,
Overlay,
Footer,
Header,
Title,
Trigger,
Portal,
Close,
//
Root as Drawer,
NestedRoot as DrawerNestedRoot,
Content as DrawerContent,
Description as DrawerDescription,
Overlay as DrawerOverlay,
Footer as DrawerFooter,
Header as DrawerHeader,
Title as DrawerTitle,
Trigger as DrawerTrigger,
Portal as DrawerPortal,
Close as DrawerClose
};
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 sure 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> Examples
Sides
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 { Button, buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
const DRAWER_SIDES = ["top", "right", "bottom", "left"] as const;
const PARAGRAPHS = [...Array(10).keys()];
</script>
<div class="flex flex-wrap gap-2">
{#each DRAWER_SIDES as side (side)}
<Drawer.Root direction={side === "bottom" ? undefined : side}>
<Drawer.Trigger
class={cn(buttonVariants({ variant: "outline" }), "capitalize")}
>
{side}
</Drawer.Trigger>
<Drawer.Content
class="data-[vaul-drawer-direction=bottom]:max-h-[50vh] data-[vaul-drawer-direction=top]:max-h-[50vh]"
>
<Drawer.Header>
<Drawer.Title>Move Goal</Drawer.Title>
<Drawer.Description>Set your daily activity goal.</Drawer.Description>
</Drawer.Header>
<div class="no-scrollbar overflow-y-auto px-4">
{#each PARAGRAPHS as paragraph (paragraph)}
<p class="mb-4 leading-normal">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
{/each}
</div>
<Drawer.Footer>
<Button>Submit</Button>
<Drawer.Close class={buttonVariants({ variant: "outline" })}
>Cancel</Drawer.Close
>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
{/each}
</div> Responsive Dialog
You can combine the Dialog and Drawer components to create a responsive dialog. This renders a Dialog 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 { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Button, buttonVariants } from "$lib/components/ui/button/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-4">
<div class="grid gap-2">
<Label for="email-{id}">Email</Label>
<Input type="email" id="email-{id}" value="[email protected]" />
</div>
<div class="grid gap-2">
<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-4 px-4">
<div class="grid gap-2">
<Label for="email-{id}">Email</Label>
<Input type="email" id="email-{id}" value="[email protected]" />
</div>
<div class="grid gap-2">
<Label for="username-{id}">Username</Label>
<Input id="username-{id}" value="@shadcn" />
</div>
<Button type="submit">Save changes</Button>
</form>
<Drawer.Footer class="pt-2">
<Drawer.Close class={buttonVariants({ variant: "outline" })}
>Cancel</Drawer.Close
>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
{/if}