Skip to content
edmi-ui

Drawer

A panel that swipes in from an edge, usually the bottom on mobile. Supports a swipe handle, snap points and non-modal use.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/drawer
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/drawer
Terminal window
yarn dlx shadcn@latest add @edmi-ui/drawer
Terminal window
bunx shadcn@latest add @edmi-ui/drawer

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/drawer
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/drawer
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/drawer
Terminal window
bunx shadcn-vue@latest add @edmi-ui/drawer

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/drawer.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/drawer.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/drawer.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/drawer.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
<Drawer>
<DrawerTrigger render={<Button variant="outline" />}>
Join MAG4
</DrawerTrigger>
<DrawerContent showHandle>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Join MAG4</DrawerTitle>
<DrawerDescription>Amount in USDC</DrawerDescription>
</DrawerHeader>
<p className="text-center font-mono text-4xl">1,000</p>
<DrawerFooter className="flex-row">
<DrawerClose
render={<Button variant="outline" className="flex-1" />}
>
Cancel
</DrawerClose>
<DrawerClose render={<Button className="flex-1" />}>
Join
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
</script>
<template>
<Drawer>
<DrawerTrigger as-child>
<Button variant="outline">Join MAG4</Button>
</DrawerTrigger>
<DrawerContent show-handle>
<div class="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Join MAG4</DrawerTitle>
<DrawerDescription>Amount in USDC</DrawerDescription>
</DrawerHeader>
<p class="text-center font-mono text-4xl">1,000</p>
<DrawerFooter class="flex-row">
<DrawerClose as-child>
<Button variant="outline" class="flex-1">Cancel</Button>
</DrawerClose>
<DrawerClose as-child>
<Button class="flex-1">Join</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
</template>
<script lang="ts">
import * as Drawer from "$lib/components/ui/drawer";
import { Button } from "$lib/components/ui/button";
</script>
<Drawer.Root>
<Drawer.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Join MAG4</Button>
{/snippet}
</Drawer.Trigger>
<!-- … -->
</Drawer.Root>
registry/ui/drawer.tsx
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
import { cn } from "cn";
import type * as React from "react";
function Drawer({
swipeDirection = "down",
...props
}: DrawerPrimitive.Root.Props) {
return (
<DrawerPrimitive.Root
data-slot="drawer"
swipeDirection={swipeDirection}
{...props}
/>
);
}
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 min-h-dvh bg-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[swiping]:duration-0",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
showHandle,
...props
}: DrawerPrimitive.Popup.Props & {
/** Render a DrawerSwipeHandle at the top (default for the bottom drawer). */
showHandle?: boolean;
}) {
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
className="fixed inset-0 z-50"
>
<DrawerPrimitive.Popup
data-slot="drawer-content"
className={cn(
// Flat: 1px border on the edge facing the page.
"group/drawer-content absolute flex flex-col gap-4 overflow-y-auto overscroll-contain border-border bg-popover text-sm text-popover-foreground outline-none transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-[swiping]:select-none data-[ending-style]:duration-200",
// down = bottom drawer
"data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:max-h-[85dvh] data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:border-t data-[swipe-direction=down]:[transform:translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))] data-[swipe-direction=down]:data-[ending-style]:translate-y-full data-[swipe-direction=down]:data-[starting-style]:translate-y-full",
// up = top drawer
"data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:max-h-[85dvh] data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:border-b data-[swipe-direction=up]:[transform:translateY(var(--drawer-swipe-movement-y,0px))] data-[swipe-direction=up]:data-[ending-style]:-translate-y-full data-[swipe-direction=up]:data-[starting-style]:-translate-y-full",
// left = drawer on the left edge (dismissed by swiping left)
"data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:w-3/4 data-[swipe-direction=left]:max-w-sm data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:border-r data-[swipe-direction=left]:[transform:translateX(var(--drawer-swipe-movement-x,0px))] data-[swipe-direction=left]:data-[ending-style]:-translate-x-full data-[swipe-direction=left]:data-[starting-style]:-translate-x-full",
// right = drawer on the right edge
"data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:w-3/4 data-[swipe-direction=right]:max-w-sm data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-l data-[swipe-direction=right]:[transform:translateX(var(--drawer-swipe-movement-x,0px))] data-[swipe-direction=right]:data-[ending-style]:translate-x-full data-[swipe-direction=right]:data-[starting-style]:translate-x-full",
className,
)}
{...props}
>
{showHandle && <DrawerSwipeHandle />}
<DrawerPrimitive.Content
data-slot="drawer-body"
className="flex flex-1 flex-col gap-4 p-[22px] pt-0 group-data-[swipe-direction=left]/drawer-content:pt-[22px] group-data-[swipe-direction=right]/drawer-content:pt-[22px]"
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
);
}
/** ✦ Grabber pill shown at the edge of a bottom/top drawer. */
function DrawerSwipeHandle({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-swipe-handle"
aria-hidden="true"
className={cn(
"mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border-2",
className,
)}
{...props}
/>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-1 text-center group-data-[swipe-direction=left]/drawer-content:text-left group-data-[swipe-direction=right]/drawer-content:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2", className)}
{...props}
/>
);
}
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"text-base leading-snug font-semibold tracking-[-0.2px] text-foreground",
className,
)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-[13px] text-muted-foreground", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerSwipeHandle,
DrawerTitle,
DrawerTrigger,
};
registry/ui/drawer/Drawer.vue
<script setup lang="ts">
import type { DrawerRootEmits, DrawerRootProps } from 'reka-ui'
import { DrawerRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DrawerRootProps>()
const emits = defineEmits<DrawerRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DrawerRoot
v-slot="slotProps"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DrawerRoot>
</template>
registry/ui/drawer/DrawerClose.vue
<script setup lang="ts">
import type { DrawerCloseProps } from 'reka-ui'
import { DrawerClose } from 'reka-ui'
const props = defineProps<DrawerCloseProps>()
</script>
<template>
<DrawerClose
data-slot="drawer-close"
v-bind="props"
>
<slot />
</DrawerClose>
</template>
registry/ui/drawer/DrawerContent.vue
<script setup lang="ts">
import type { DrawerContentEmits, DrawerContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DrawerContent,
DrawerPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
import DrawerOverlay from './DrawerOverlay.vue'
import DrawerSwipeHandle from './DrawerSwipeHandle.vue'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DrawerContentProps & {
class?: HTMLAttributes['class']
/** Render a DrawerSwipeHandle at the top (default for the bottom drawer). */
showHandle?: boolean
}>()
const emits = defineEmits<DrawerContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'showHandle')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent
data-slot="drawer-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
// Flat: 1px border on the edge facing the page.
'group/drawer-content fixed z-50 flex flex-col gap-4 overflow-y-auto overscroll-contain border-border bg-popover text-sm text-popover-foreground outline-none will-change-transform transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-[swiping]:select-none data-[swiping]:duration-0 data-[state=open]:animate-in data-[state=closed]:animate-out',
// down = bottom drawer
'data-[swipe-direction=down]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:max-h-[85dvh] data-[swipe-direction=down]:rounded-t-2xl data-[swipe-direction=down]:border-t data-[swipe-direction=down]:[transform:translateY(calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y,0px)))] data-[swipe-direction=down]:data-[state=open]:slide-in-from-bottom data-[swipe-direction=down]:data-[state=closed]:slide-out-to-bottom',
// up = top drawer
'data-[swipe-direction=up]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:max-h-[85dvh] data-[swipe-direction=up]:rounded-b-2xl data-[swipe-direction=up]:border-b data-[swipe-direction=up]:[transform:translateY(var(--drawer-swipe-movement-y,0px))] data-[swipe-direction=up]:data-[state=open]:slide-in-from-top data-[swipe-direction=up]:data-[state=closed]:slide-out-to-top',
// left = drawer on the left edge (dismissed by swiping left)
'data-[swipe-direction=left]:inset-y-0 data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:w-3/4 data-[swipe-direction=left]:max-w-sm data-[swipe-direction=left]:rounded-r-2xl data-[swipe-direction=left]:border-r data-[swipe-direction=left]:[transform:translateX(var(--drawer-swipe-movement-x,0px))] data-[swipe-direction=left]:data-[state=open]:slide-in-from-left data-[swipe-direction=left]:data-[state=closed]:slide-out-to-left',
// right = drawer on the right edge
'data-[swipe-direction=right]:inset-y-0 data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:w-3/4 data-[swipe-direction=right]:max-w-sm data-[swipe-direction=right]:rounded-l-2xl data-[swipe-direction=right]:border-l data-[swipe-direction=right]:[transform:translateX(var(--drawer-swipe-movement-x,0px))] data-[swipe-direction=right]:data-[state=open]:slide-in-from-right data-[swipe-direction=right]:data-[state=closed]:slide-out-to-right',
props.class,
)"
>
<DrawerSwipeHandle v-if="showHandle" />
<div
data-slot="drawer-body"
class="flex flex-1 flex-col gap-4 p-[22px] pt-0 group-data-[swipe-direction=left]/drawer-content:pt-[22px] group-data-[swipe-direction=right]/drawer-content:pt-[22px]"
>
<slot />
</div>
</DrawerContent>
</DrawerPortal>
</template>
registry/ui/drawer/DrawerDescription.vue
<script setup lang="ts">
import type { DrawerDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DrawerDescription } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<DrawerDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DrawerDescription
data-slot="drawer-description"
v-bind="delegatedProps"
:class="cn('text-[13px] text-muted-foreground', props.class)"
>
<slot />
</DrawerDescription>
</template>
registry/ui/drawer/DrawerFooter.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="drawer-footer"
:class="cn('mt-auto flex flex-col gap-2', props.class)"
>
<slot />
</div>
</template>
registry/ui/drawer/DrawerHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="drawer-header"
:class="cn('flex flex-col gap-1 text-center group-data-[swipe-direction=left]/drawer-content:text-left group-data-[swipe-direction=right]/drawer-content:text-left', props.class)"
>
<slot />
</div>
</template>
registry/ui/drawer/DrawerOverlay.vue
<script setup lang="ts">
import type { DrawerOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DrawerOverlay } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<DrawerOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DrawerOverlay
data-slot="drawer-overlay"
v-bind="delegatedProps"
:class="cn('fixed inset-0 z-50 min-h-dvh bg-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))] duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[swiping]:duration-0', props.class)"
/>
</template>
registry/ui/drawer/DrawerSwipeHandle.vue
<script setup lang="ts">
import type { DrawerHandleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DrawerHandle, useForwardProps } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
// ✦ Grabber pill shown at the edge of a bottom/top drawer.
const props = defineProps<DrawerHandleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DrawerHandle
data-slot="drawer-swipe-handle"
v-bind="forwardedProps"
:class="cn('mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border-2', props.class)"
>
<slot />
</DrawerHandle>
</template>
registry/ui/drawer/DrawerTitle.vue
<script setup lang="ts">
import type { DrawerTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DrawerTitle } from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<DrawerTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DrawerTitle
data-slot="drawer-title"
v-bind="delegatedProps"
:class="cn('text-base leading-snug font-semibold tracking-[-0.2px] text-foreground', props.class)"
>
<slot />
</DrawerTitle>
</template>
registry/ui/drawer/DrawerTrigger.vue
<script setup lang="ts">
import type { DrawerTriggerProps } from 'reka-ui'
import { DrawerTrigger } from 'reka-ui'
const props = defineProps<DrawerTriggerProps>()
</script>
<template>
<DrawerTrigger
data-slot="drawer-trigger"
v-bind="props"
>
<slot />
</DrawerTrigger>
</template>
registry/ui/drawer/index.ts
export { default as Drawer } from "./Drawer.vue";
export { default as DrawerClose } from "./DrawerClose.vue";
export { default as DrawerContent } from "./DrawerContent.vue";
export { default as DrawerDescription } from "./DrawerDescription.vue";
export { default as DrawerFooter } from "./DrawerFooter.vue";
export { default as DrawerHeader } from "./DrawerHeader.vue";
export { default as DrawerOverlay } from "./DrawerOverlay.vue";
export { default as DrawerSwipeHandle } from "./DrawerSwipeHandle.vue";
export { default as DrawerTitle } from "./DrawerTitle.vue";
export { default as DrawerTrigger } from "./DrawerTrigger.vue";
src/lib/registry/ui/drawer/drawer-close.svelte
<script lang="ts">
import { Drawer as Primitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: Primitive.CloseProps = $props();
</script>
<Primitive.Close bind:ref data-slot="drawer-close" {...restProps} />
src/lib/registry/ui/drawer/drawer-content.svelte
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
import DrawerOverlay from "./drawer-overlay.svelte";
import DrawerPortal from "./drawer-portal.svelte";
import DrawerSwipeHandle from "./drawer-swipe-handle.svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
showHandle,
children,
...restProps
}: DrawerPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
/** Render a DrawerSwipeHandle at the top (default for the bottom drawer). */
showHandle?: boolean;
} = $props();
</script>
<DrawerPortal {...portalProps}>
<DrawerOverlay />
<DrawerPrimitive.Content
bind:ref
data-slot="drawer-content"
class={cn(
// Flat: a 1px border on the edge facing the page.
"group/drawer-content fixed z-50 flex h-auto flex-col gap-4 overflow-y-auto overscroll-contain border-border bg-popover text-sm text-popover-foreground outline-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-[85dvh] data-[vaul-drawer-direction=bottom]:rounded-t-2xl data-[vaul-drawer-direction=bottom]:border-t",
"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-[85dvh] data-[vaul-drawer-direction=top]:rounded-b-2xl data-[vaul-drawer-direction=top]:border-b",
"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]:max-w-sm data-[vaul-drawer-direction=left]:rounded-r-2xl 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]:max-w-sm data-[vaul-drawer-direction=right]:rounded-l-2xl data-[vaul-drawer-direction=right]:border-l",
className
)}
{...restProps}
>
{#if showHandle}
<DrawerSwipeHandle />
{/if}
<div
data-slot="drawer-body"
class="flex flex-1 flex-col gap-4 p-[22px] pt-0 group-data-[vaul-drawer-direction=left]/drawer-content:pt-[22px] group-data-[vaul-drawer-direction=right]/drawer-content:pt-[22px]"
>
{@render children?.()}
</div>
</DrawerPrimitive.Content>
</DrawerPortal>
src/lib/registry/ui/drawer/drawer-description.svelte
<script lang="ts">
import { Drawer as Primitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: Primitive.DescriptionProps = $props();
</script>
<Primitive.Description
bind:ref
data-slot="drawer-description"
class={cn("text-[13px] text-muted-foreground", className)}
{...restProps}
/>
src/lib/registry/ui/drawer/drawer-footer.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/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", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/drawer/drawer-header.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/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-header"
class={cn("flex flex-col gap-1 text-center group-data-[vaul-drawer-direction=left]/drawer-content:text-left group-data-[vaul-drawer-direction=right]/drawer-content:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/drawer/drawer-nested.svelte
<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} />
src/lib/registry/ui/drawer/drawer-overlay.svelte
<script lang="ts">
import { Drawer as Primitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: Primitive.OverlayProps = $props();
</script>
<Primitive.Overlay
bind:ref
data-slot="drawer-overlay"
class={cn("fixed inset-0 z-50 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className)}
{...restProps}
/>
src/lib/registry/ui/drawer/drawer-portal.svelte
<script lang="ts">
import { Drawer as Primitive } from "vaul-svelte";
let { ...restProps }: Primitive.PortalProps = $props();
</script>
<Primitive.Portal {...restProps} />
src/lib/registry/ui/drawer/drawer-swipe-handle.svelte
<script lang="ts">
import { Drawer as Primitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: Primitive.HandleProps = $props();
</script>
<Primitive.Handle
bind:ref
data-slot="drawer-swipe-handle"
class={cn("mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border-2", className)}
{...restProps}
/>
src/lib/registry/ui/drawer/drawer-title.svelte
<script lang="ts">
import { Drawer as Primitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: Primitive.TitleProps = $props();
</script>
<Primitive.Title
bind:ref
data-slot="drawer-title"
class={cn("text-base leading-snug font-semibold tracking-[-0.2px] text-foreground", className)}
{...restProps}
/>
src/lib/registry/ui/drawer/drawer-trigger.svelte
<script lang="ts">
import { Drawer as Primitive } from "vaul-svelte";
let { ref = $bindable(null), ...restProps }: Primitive.TriggerProps = $props();
</script>
<Primitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />
src/lib/registry/ui/drawer/drawer.svelte
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
// Edmi API parity with the React/Vue drawer: `swipeDirection` is the dismiss direction
// (down = bottom drawer). vaul-svelte names the edge instead (`direction`); either works.
type SwipeDirection = "up" | "down" | "left" | "right";
const edgeOf: Record<SwipeDirection, "top" | "bottom" | "left" | "right"> = {
up: "top",
down: "bottom",
left: "left",
right: "right",
};
let {
shouldScaleBackground = true,
open = $bindable(false),
activeSnapPoint = $bindable(null),
swipeDirection,
direction,
...restProps
}: DrawerPrimitive.RootProps & { swipeDirection?: SwipeDirection } = $props();
const edge = $derived(direction ?? (swipeDirection ? edgeOf[swipeDirection] : undefined));
</script>
<DrawerPrimitive.Root
{shouldScaleBackground}
bind:open
bind:activeSnapPoint
direction={edge}
{...restProps}
/>
src/lib/registry/ui/drawer/index.ts
import Root from "./drawer.svelte";
import Close from "./drawer-close.svelte";
import Content from "./drawer-content.svelte";
import Description from "./drawer-description.svelte";
import Footer from "./drawer-footer.svelte";
import Header from "./drawer-header.svelte";
import NestedRoot from "./drawer-nested.svelte";
import Overlay from "./drawer-overlay.svelte";
import Portal from "./drawer-portal.svelte";
import SwipeHandle from "./drawer-swipe-handle.svelte";
import Title from "./drawer-title.svelte";
import Trigger from "./drawer-trigger.svelte";
export {
Close,
Close as DrawerClose,
Content,
Content as DrawerContent,
Description,
Description as DrawerDescription,
Footer,
Footer as DrawerFooter,
Header,
Header as DrawerHeader,
NestedRoot,
NestedRoot as DrawerNestedRoot,
Overlay,
Overlay as DrawerOverlay,
Portal,
Portal as DrawerPortal,
Root,
//
Root as Drawer,
SwipeHandle,
SwipeHandle as DrawerSwipeHandle,
Title,
Title as DrawerTitle,
Trigger,
Trigger as DrawerTrigger,
};
Prop Type Default
Drawer swipeDirection down | up | left | right down
Drawer snapPoints (number | string)[] (0-1 = viewport fraction, "148px", "30rem") none
Drawer snapPoint / onSnapPointChange controlled snap point uncontrolled
Drawer modal boolean | "trap-focus" true
DrawerContent showHandle boolean false

Parts: Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerFooter, DrawerClose, DrawerSwipeHandle. Built on the Base UI Drawer (not vaul).

  • DrawerSwipeHandle grabber and showHandle; hard 4px strong lip, no blur.
  • Svelte (vaul-svelte) names the edge in direction (top | bottom | left | right). Edmi also accepts swipeDirection for parity with React and Vue and maps it: up -> top, down -> bottom, left -> left, right -> right. direction wins if both are set.