A swipe-to-dismiss panel from any edge on Vaul, with grab handle, header, scrollable body and footer, drag-safe inner content and reduced-motion handling.
3 items
$189.00
"use client";
import { CreditCard, Package } from "lucide-react";
import { buttonVariants } from "@/components/ballmac/button";
import {
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ballmac/drawer";
const lines = [
["Studio headphones", "1 × $129.00"],
["USB-C cable (2 m)", "2 × $14.00"],
["Carry case", "1 × $32.00"],
];
export default function DrawerDemo() {
return (
<div className="flex w-full max-w-sm items-center justify-between gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-lg bg-muted">
<Package aria-hidden="true" className="size-5" />
</span>
<div>
<p className="text-sm font-semibold">3 items</p>
<p className="text-xs text-muted-foreground tabular-nums">$189.00</p>
</div>
</div>
<Drawer>
<DrawerTrigger className={buttonVariants()}>Review order</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>Review your order</DrawerTitle>
<DrawerDescription>Free shipping applied. Delivery by Friday.</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<ul className="grid gap-2">
{lines.map(([name, qty]) => (
<li key={name} className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="font-medium">{name}</span>
<span className="text-muted-foreground tabular-nums">{qty}</span>
</li>
))}
</ul>
<p className="mt-4 flex justify-between text-sm font-semibold">
<span>Total</span>
<span className="tabular-nums">$189.00</span>
</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose className={buttonVariants()}>
<CreditCard aria-hidden="true" /> Pay $189.00
</DrawerClose>
<DrawerClose className={buttonVariants({ variant: "outline" })}>Keep shopping</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/drawerInstall the dependencies.
$ pnpm add vaul@^1Copy the source into your project.
components/ballmac/drawer.tsx// Ballmac UI: Drawer. https://ui.ballmac.com/components/drawer // Based on shadcn/ui Drawer (MIT, Copyright (c) 2023 shadcn) on Vaul (MIT, Copyright (c) 2023 Emil Kowalski), restyled with tokens, a grab handle, all four directions, and a scrollable body. "use client"; import * as React from "react"; import { Drawer as Primitive } from "vaul"; import { cn } from "@/lib/utils"; type DrawerProps = React.ComponentProps<typeof Primitive.Root>; /** Root. Props come from Vaul: `direction`, `snapPoints`, `activeSnapPoint`, `dismissible`, `shouldScaleBackground`, `modal`. */ function Drawer({ shouldScaleBackground = false, ...props }: DrawerProps) { return <Primitive.Root data-slot="drawer" shouldScaleBackground={shouldScaleBackground} {...props} />; } type DrawerTriggerProps = React.ComponentProps<typeof Primitive.Trigger>; function DrawerTrigger(props: DrawerTriggerProps) { return <Primitive.Trigger data-slot="drawer-trigger" {...props} />; } type DrawerCloseProps = React.ComponentProps<typeof Primitive.Close>; function DrawerClose(props: DrawerCloseProps) { return <Primitive.Close data-slot="drawer-close" {...props} />; } type DrawerContentProps = React.ComponentProps<typeof Primitive.Content> & { /** Show the grab handle. On by default for bottom drawers. */ handle?: boolean; }; function DrawerContent({ className, children, handle, ...props }: DrawerContentProps) { return ( <Primitive.Portal> <Primitive.Overlay data-slot="drawer-overlay" className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[2px]" /> <Primitive.Content data-slot="drawer-content" className={cn( "group/drawer-content fixed z-50 flex h-auto flex-col bg-card text-card-foreground shadow-[0_24px_48px_-12px_rgb(0_0_0/0.3)] 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-[90dvh] 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-[90dvh] data-[vaul-drawer-direction=top]:rounded-b-2xl data-[vaul-drawer-direction=top]:border-b", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-[min(24rem,calc(100vw-2.5rem))] data-[vaul-drawer-direction=right]:border-s", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-[min(24rem,calc(100vw-2.5rem))] data-[vaul-drawer-direction=left]:border-e", "motion-reduce:!transition-none", className, )} {...props} > <span aria-hidden="true" className={cn( "mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-muted-foreground/30", handle === false ? "hidden" : handle ? "block" : "hidden group-data-[vaul-drawer-direction=bottom]/drawer-content:block", )} /> {children} </Primitive.Content> </Primitive.Portal> ); } type DrawerHeaderProps = React.ComponentProps<"div">; function DrawerHeader({ className, ...props }: DrawerHeaderProps) { return ( <div data-slot="drawer-header" className={cn("grid gap-1.5 px-5 pt-4 text-center sm:text-start group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center", className)} {...props} /> ); } type DrawerBodyProps = React.ComponentProps<"div">; /** Scrollable middle region. Focusable so keyboard users can scroll long content. */ function DrawerBody({ className, ...props }: DrawerBodyProps) { return ( <div data-slot="drawer-body" tabIndex={0} data-vaul-no-drag="" className={cn( "min-h-0 flex-1 overflow-y-auto px-5 py-3 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50", className, )} {...props} /> ); } type DrawerFooterProps = React.ComponentProps<"div">; function DrawerFooter({ className, ...props }: DrawerFooterProps) { return ( <div data-slot="drawer-footer" className={cn("mt-auto flex flex-col gap-2 px-5 pt-3 pb-[max(1.25rem,env(safe-area-inset-bottom))]", className)} {...props} /> ); } type DrawerTitleProps = React.ComponentProps<typeof Primitive.Title>; function DrawerTitle({ className, ...props }: DrawerTitleProps) { return ( <Primitive.Title data-slot="drawer-title" className={cn("text-lg leading-tight font-semibold tracking-tight", className)} {...props} /> ); } type DrawerDescriptionProps = React.ComponentProps<typeof Primitive.Description>; function DrawerDescription({ className, ...props }: DrawerDescriptionProps) { return ( <Primitive.Description data-slot="drawer-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> ); } export { Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerBody, DrawerFooter, DrawerTitle, DrawerDescription, type DrawerProps, type DrawerTriggerProps, type DrawerCloseProps, type DrawerContentProps, type DrawerHeaderProps, type DrawerBodyProps, type DrawerFooterProps, type DrawerTitleProps, type DrawerDescriptionProps, };Update the import paths to match your project setup.
Usage
import { Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerBody, DrawerFooter, DrawerTitle, DrawerDescription } from "@/components/ballmac/drawer"The full example is in the Code tab above.
Examples
Four directions
"use client";
import { buttonVariants } from "@/components/ballmac/button";
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ballmac/drawer";
const directions = ["left", "right", "top", "bottom"] as const;
export default function DrawerStates() {
return (
<div className="flex flex-wrap gap-2">
{directions.map((direction) => (
<Drawer key={direction} direction={direction}>
<DrawerTrigger className={buttonVariants({ variant: "outline", size: "sm" })}>
{direction[0].toUpperCase() + direction.slice(1)}
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Notifications</DrawerTitle>
<DrawerDescription>Drag, swipe or press Escape to close.</DrawerDescription>
</DrawerHeader>
<DrawerBody>
<ul className="grid gap-2 text-sm">
{["Ana commented on Launch plan", "Build #482 passed", "Kofi invited you to Pricing"].map((t) => (
<li key={t} className="rounded-lg border p-3">{t}</li>
))}
</ul>
</DrawerBody>
</DrawerContent>
</Drawer>
))}
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
handleShow the grab handle. On by default for bottom drawers. | boolean | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab / Shift+Tab | Cycles focus inside the drawer |
| Escape | Closes and returns focus to the trigger |
| Drag / swipe | Dismisses by gesture |
Use with AI
Bottom drawers suit mobile actions; use direction left or right for side panels. Compose DrawerHeader, DrawerBody and DrawerFooter. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Drawer (@ballmac/drawer) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Mobile-first confirmations and pickers
- Panels users may want to flick away
Not for
- Desktop settings panels; use sheet
- Critical confirmations; use alert-dialog
Registry JSON: https://ui.ballmac.com/r/drawer.json
Credits
Based on shadcn/ui Drawer, adapted by Ballmac. Free to use in personal and commercial projects.
- npm
- vaul@^1
- Registry
- shadcn/utils
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Sheet
A modal panel that slides from any edge, with header, scrollable body and footer parts, a grab handle on bottom sheets, and safe-area padding.
Accordion
Vertically stacked disclosure sections on Radix Accordion, single or multiple open, with hairline dividers, a rotating plus and height animation.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.