An edge-attached panel with draggable vertical dismissal.
Standalone
A left-anchored drawer showing a proximity-hover nav list.
Side
The same drawer anchored to each edge, with content shaped for its position — a banner up top, settings on the right, a draggable icon-row action sheet on the bottom, and the same nav list as Standalone on the left.
Install
Install the packages this component imports.
bun add @base-ui/react motionAlso requires lib/utils.ts, lib/springs.ts (see Installation).
Source
Copy and paste the following code into your project.
"use client";
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Dialog as DrawerPrimitive } from "@base-ui/react/dialog";
import { motion, useDragControls, type PanInfo } from "motion/react";
import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
type Side = "top" | "right" | "bottom" | "left";
const VELOCITY_THRESHOLD = 400;
const CLOSE_THRESHOLD_RATIO = 0.25;
const FALLBACK_SIZE = 400;
interface DrawerContextValue {
actionsRef: React.RefObject<DrawerPrimitive.Root.Actions | null>;
}
const DrawerContext = createContext<DrawerContextValue | null>(null);
function Drawer({ children, ...props }: DrawerPrimitive.Root.Props) {
const actionsRef = useRef<DrawerPrimitive.Root.Actions>(null);
return (
<DrawerContext.Provider value={{ actionsRef }}>
<DrawerPrimitive.Root data-slot="drawer" actionsRef={actionsRef} {...props}>
{children}
</DrawerPrimitive.Root>
</DrawerContext.Provider>
);
}
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerOverlay({ className, ...props }: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
render={(backdropProps, state) => {
const exiting = state.transitionStatus === "ending";
return (
<motion.div
{...(backdropProps as Record<string, unknown>)}
className={cn("fixed inset-0 z-50 bg-black/30 backdrop-blur-none", className)}
initial={{ opacity: 0 }}
animate={{ opacity: exiting ? 0 : 1 }}
transition={exiting ? spring.slow.exit : spring.slow.enter}
/>
);
}}
{...props}
/>
);
}
function DrawerContent({
className,
children,
side = "right",
showHandle = side === "top" || side === "bottom",
...props
}: DrawerPrimitive.Popup.Props & {
side?: Side;
showHandle?: boolean;
}) {
const ctx = useContext(DrawerContext);
const dragControls = useDragControls();
const panelRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<number | null>(null);
const axis: "x" | "y" = side === "left" || side === "right" ? "x" : "y";
const sign = side === "bottom" || side === "right" ? 1 : -1;
const draggable = axis === "y";
const dimension = size ?? FALLBACK_SIZE;
const closedOffset = sign * dimension;
useLayoutEffect(() => {
const el = panelRef.current;
if (!el) return;
const measure = () =>
setSize(axis === "y" ? el.getBoundingClientRect().height : el.getBoundingClientRect().width);
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [axis]);
const handleDragEnd = useCallback(
(_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const offset = axis === "y" ? info.offset.y : info.offset.x;
const velocity = axis === "y" ? info.velocity.y : info.velocity.x;
const closingOffset = sign * offset;
const closingVelocity = sign * velocity;
if (
closingVelocity > VELOCITY_THRESHOLD ||
closingOffset > dimension * CLOSE_THRESHOLD_RATIO
) {
ctx?.actionsRef.current?.close();
}
},
[axis, sign, dimension, ctx],
);
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Popup
ref={panelRef}
data-slot="drawer-content"
data-side={side}
render={(popupProps, state) => {
const exiting = state.transitionStatus === "ending";
return (
<motion.div
{...(popupProps as Record<string, unknown>)}
{...(props as Record<string, unknown>)}
className={cn(
"fixed z-50 flex flex-col overflow-hidden bg-background shadow-panel outline-none",
side === "left" && "inset-y-0 left-0 w-64 max-w-[85vw] rounded-r-xl",
side === "right" && "inset-y-0 right-0 w-64 max-w-[85vw] rounded-l-xl",
side === "top" && "inset-x-0 top-0 h-auto max-h-[85vh] rounded-b-xl",
side === "bottom" && "inset-x-0 bottom-0 h-auto max-h-[85vh] rounded-t-xl",
className,
)}
drag={draggable ? axis : false}
dragControls={dragControls}
dragListener={false}
dragMomentum={false}
dragConstraints={
axis === "y"
? {
top: side === "top" ? closedOffset : 0,
bottom: side === "bottom" ? closedOffset : 0,
}
: {
left: side === "left" ? closedOffset : 0,
right: side === "right" ? closedOffset : 0,
}
}
dragElastic={
side === "bottom"
? { top: 0.15, bottom: 1 }
: side === "top"
? { top: 1, bottom: 0.15 }
: side === "right"
? { left: 0.15, right: 1 }
: { left: 1, right: 0.15 }
}
onDragEnd={draggable ? handleDragEnd : undefined}
initial={{ [axis]: closedOffset }}
animate={{ [axis]: exiting ? closedOffset : 0 }}
transition={exiting ? spring.slow.exit : spring.slow.enter}
>
{showHandle && (
<div
data-slot="drawer-handle"
onPointerDown={(event) => dragControls.start(event)}
className={cn(
"mx-auto h-1 w-9 shrink-0 touch-none rounded-full bg-border",
side === "top" ? "order-last mt-1 mb-2" : "mt-2 mb-1",
)}
/>
)}
{axis === "y" ? (
<div className="mx-auto flex w-full max-w-lg flex-1 flex-col overflow-hidden">
{children}
</div>
) : (
children
)}
</motion.div>
);
}}
/>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: { className?: string; children?: ReactNode }) {
return (
<div
data-slot="drawer-header"
className={cn("flex h-14 shrink-0 items-center border-b border-border px-6", className)}
{...props}
/>
);
}
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-control font-medium text-foreground", className)}
{...props}
/>
);
}
function DrawerDescription({ className, ...props }: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-caption text-muted-foreground", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerTrigger,
DrawerClose,
DrawerPortal,
DrawerOverlay,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
};API Reference
DrawerdefaultOpenfalse | boolean Whether the drawer is initially open, uncontrolled. |
open | boolean Controlled open state. |
onOpenChange | (open: boolean) => void Called when the open state changes — including from a drag dismissal. |
API Reference
DrawerContentside"right" | "top" | "right" | "bottom" | "left" Which viewport edge the drawer slides in from. |
showHandletrue for top/bottom, false for left/right | boolean Whether to render the draggable grab bar. |
children | ReactNode Drawer content — typically DrawerHeader and body content. |
API Reference
DrawerTitleDrawerDescriptionchildren | ReactNode Title or description content. |