- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Open the panel from any edge. Start and end drawers take a width; top and bottom drawers span the viewport.
Installation
$ pnpm dlx shadcn@latest add @cobalt/drawer
Source
"use client";
import { useDirection } from "@base-ui/react/direction-provider";
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
import { RiCloseLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { useDialogScrollFade } from "@/components/hooks/use-dialog-scroll-fade";
import { Button } from "@/components/ui/button";
// A panel that slides in from an edge over a dimmed page. The panel floats
// 8px inside the viewport on phones and 12px from sm up, at a 24px radius on a
// 1px hairline border with the soft shadow-xs, so every edge is free of the
// screen. Start and end follow the reading direction in the source; Base UI
// only knows swipe directions, so start/end map to left/right.
//
// Motion: the panel slides in from past its edge and sharpens from a 4px blur
// over 300ms on the iOS sheet curve, and plays the same transition back out.
// The black/40 backdrop fades with it. Reduced motion keeps the fade and drops
// the slide and the blur.
/** The edge the panel slides in from. */
type DrawerPlacement = "start" | "end" | "top" | "bottom";
/** Width of a start or end drawer. Top and bottom drawers span the viewport. */
type DrawerSize = "sm" | "default" | "lg";
const PLACEMENT_DIRECTION: Record<
DrawerPlacement,
NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>
> = {
bottom: "down",
end: "right",
start: "left",
top: "up",
};
// Side drawers only; top and bottom drawers span the viewport.
const SIZE: Record<DrawerSize, string> = {
lg: "data-[swipe-axis=x]:max-w-[600px]",
default: "data-[swipe-axis=x]:max-w-[400px]",
sm: "data-[swipe-axis=x]:max-w-[320px]",
};
interface DrawerContextProps {
hasSnapPoints: boolean;
modal: DrawerPrimitive.Root.Props["modal"];
showSwipeHandle: boolean;
size: DrawerSize;
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
}
const DrawerContext = React.createContext<DrawerContextProps | null>(null);
function useDrawer() {
const context = React.useContext(DrawerContext);
if (!context) {
throw new Error("useDrawer must be used within a Drawer.");
}
return context;
}
function Drawer({
modal = true,
placement,
showSwipeHandle = false,
size = "default",
snapPoints,
swipeDirection,
...props
}: DrawerPrimitive.Root.Props & {
/** The edge the panel slides in from. */
placement?: DrawerPlacement;
showSwipeHandle?: boolean;
/** Width of a start or end drawer: 320, 400 (default), or 600px. */
size?: DrawerSize;
}) {
const direction = useDirection();
const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
const resolvedSwipeDirection =
swipeDirection ??
(placement &&
direction === "rtl" &&
(placement === "start" || placement === "end")
? PLACEMENT_DIRECTION[placement === "start" ? "end" : "start"]
: placement
? PLACEMENT_DIRECTION[placement]
: "down");
const contextValue = React.useMemo(
() => ({
hasSnapPoints,
modal,
showSwipeHandle,
size,
swipeDirection: resolvedSwipeDirection,
}),
[hasSnapPoints, modal, showSwipeHandle, size, resolvedSwipeDirection]
);
return (
<DrawerContext.Provider value={contextValue}>
<DrawerPrimitive.Root
data-slot="drawer"
modal={modal}
snapPoints={snapPoints}
swipeDirection={resolvedSwipeDirection}
{...props}
/>
</DrawerContext.Provider>
);
}
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-[var(--drawer-backdrop,rgb(0_0_0/0.4))] opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-300 ease-out select-none motion-reduce:transition-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*300ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-[-webkit-touch-callout:none]:absolute",
className
)}
{...props}
/>
);
}
function DrawerSwipeHandle({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-swipe-handle"
aria-hidden="true"
className={cn(
"relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 motion-reduce:transition-none group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 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=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-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-[100px] group-data-[swipe-axis=x]/drawer-popup:after:w-1.5 group-data-[swipe-axis=y]/drawer-popup:after:h-1.5 group-data-[swipe-axis=y]/drawer-popup:after:w-[100px] active:cursor-grabbing",
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: DrawerPrimitive.Popup.Props) {
const { hasSnapPoints, modal, showSwipeHandle, size, swipeDirection } =
useDrawer();
const direction = useDirection();
const [viewport, setViewport] = React.useState<{
height: number;
top: number;
} | null>(null);
React.useEffect(() => {
const { visualViewport } = window;
if (!visualViewport) {
return;
}
const update = () =>
setViewport({
height: visualViewport.height,
top: visualViewport.offsetTop,
});
update();
visualViewport.addEventListener("resize", update);
visualViewport.addEventListener("scroll", update);
return () => {
visualViewport.removeEventListener("resize", update);
visualViewport.removeEventListener("scroll", update);
};
}, []);
const swipeAxis =
swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";
return (
<DrawerPortal data-slot="drawer-portal">
{modal === true && (
<DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />
)}
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
data-modal={modal}
dir={direction}
className="pointer-events-none fixed inset-x-0 top-0 z-50 h-dvh select-none data-[modal=true]:pointer-events-auto"
style={
viewport ? { height: viewport.height, top: viewport.top } : undefined
}
>
<DrawerPrimitive.Popup
data-slot="drawer-popup"
data-swipe-axis={swipeAxis}
data-snap-points={hasSnapPoints ? "" : undefined}
className={cn(
// Panel inset: 8px on phones, 12px from sm up, on every edge the
// panel does not span.
"[--drawer-inset:8px] sm:[--drawer-inset:12px]",
// Base.
"group/drawer-popup pointer-events-auto absolute z-50 m-(--drawer-inset) 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 rounded-[24px] border border-[var(--surface-strong,var(--border))] bg-[var(--surface-overlay,var(--popover))] text-sm text-popover-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] transition-[transform,height,opacity,filter] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform outline-none select-none motion-reduce:transition-none [interpolate-size:allow-keywords]",
// Nested.
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
// Bleed.
"after:pointer-events-none after:absolute after:bg-[var(--drawer-bleed-background,var(--surface-overlay,var(--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",
// Sizing. Side drawers fill the viewport's height; top and bottom
// drawers stop at 85% of it. The width is the viewport minus the
// inset on both sides, then capped by the size below.
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:calc(100%_-_calc(var(--drawer-inset)*2))] data-[swipe-axis=y]:[--drawer-content-max-height:85%] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100%]",
SIZE[size],
// Stack.
"[--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]",
// Transitions.
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*300ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*300ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*300ms)]",
// Motion: slide and sharpen.
"motion-safe:data-ending-style:blur-[4px] motion-safe:data-starting-style:blur-[4px]",
// Reduced motion: fade in place, no slide and no blur.
"motion-reduce:data-ending-style:transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] motion-reduce:data-starting-style:opacity-0",
// Axis: y.
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
// Axis: x.
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
// Direction: down.
"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)))]",
// Direction: up.
"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)))]",
// Direction: left.
"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%))]",
// Direction: right.
"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%))]",
className
)}
{...props}
>
{showSwipeHandle && <DrawerSwipeHandle />}
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-4 overflow-clip overscroll-contain rounded-[inherit] py-6 transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text motion-reduce:transition-none 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"
)}
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
);
}
function DrawerHeader({
className,
children,
showCloseButton = true,
closeLabel = "Close",
...props
}: React.ComponentProps<"div"> & {
/** Show the dismiss button in the top corner. Default true. */
showCloseButton?: boolean;
/** Accessible name for the dismiss button. */
closeLabel?: string;
}) {
return (
<div
data-slot="drawer-header"
className={cn("flex shrink-0 items-start gap-3 px-6", className)}
{...props}
>
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
{showCloseButton ? (
<DrawerPrimitive.Close
data-slot="drawer-close"
aria-label={closeLabel}
className="-me-1 shrink-0"
render={<Button size="icon-xs" variant="close" />}
>
<RiCloseLine className="size-6" />
</DrawerPrimitive.Close>
) : null}
</div>
);
}
function DrawerBody({
className,
onScroll,
children,
...props
}: React.ComponentProps<"div">) {
const fade = useDialogScrollFade();
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div
data-slot="drawer-body"
onScroll={(event) => {
fade.onScroll(event);
onScroll?.(event);
}}
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-1 text-[14px] leading-[20px] text-muted-foreground",
className
)}
{...props}
>
{children}
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6"
>
<div
className="absolute inset-0 backdrop-blur-[1px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
style={{ opacity: fade.progress }}
/>
<div
className="absolute inset-x-0 top-0 h-4 backdrop-blur-[4px] [mask-image:linear-gradient(to_bottom,black,transparent)]"
style={{ opacity: fade.progress }}
/>
<div
className="absolute inset-0 bg-linear-to-b from-[var(--surface-overlay,var(--popover))] to-transparent"
style={{ opacity: fade.progress }}
/>
</div>
</div>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn(
"mt-2 flex shrink-0 flex-col-reverse gap-2 px-6 sm:flex-row sm:items-center sm:justify-end",
className
)}
{...props}
/>
);
}
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"text-[18px] leading-[26px] font-medium text-foreground",
className
)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn(
"text-[14px] leading-[20px] text-balance text-muted-foreground",
className
)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerSwipeHandle,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerBody,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
export type { DrawerPlacement, DrawerSize }; On this page