Drawer
Side panel that slides in from the left or right with a spring, backdrop blur, body scroll lock and esc-to-close.
Preview
"use client";
import { useState } from "react";
import { Drawer } from "@/components/motion/drawer";
export function DrawerPreview() {
const [open, setOpen] = useState(false);
const [side, setSide] = useState<"left" | "right">("right");
const openWith = (s: "left" | "right") => {
setSide(s);
setOpen(true);
};
return (
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => openWith("left")}
className="inline-flex h-10 items-center rounded-full border border-border bg-card px-5 text-sm font-medium text-foreground transition-colors hover:bg-card/70"
>
Open left
</button>
<button
type="button"
onClick={() => openWith("right")}
className="inline-flex h-10 items-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Open right
</button>
<Drawer
open={open}
onOpenChange={setOpen}
side={side}
ariaLabel="Demo drawer"
className="gap-4 p-6"
>
<h2 className="text-sm font-semibold text-foreground">Drawer</h2>
<p className="text-sm text-muted-foreground">
Slides in from the {side}. Press Esc or click outside to close.
</p>
</Drawer>
</div>
);
}
"use client";
// beui.dev/components/motion/drawer
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, type ReactNode } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { PresenceGate } from "@/lib/presence-gate";
import { cn } from "@/lib/utils";
export interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: "left" | "right";
children: ReactNode;
/** Class for the panel surface. */
className?: string;
/** Class for the backdrop. */
backdropClassName?: string;
ariaLabel?: string;
/** Close when the backdrop is clicked. Default true. */
dismissable?: boolean;
}
export function Drawer({
open,
onOpenChange,
side = "right",
children,
className,
backdropClassName,
ariaLabel,
dismissable = true,
}: DrawerProps) {
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onOpenChange]);
const offscreen = side === "right" ? "100%" : "-100%";
// Two fixed siblings, no wrapper: the backdrop spans the viewport edges but
// paints the scrim, and the panel is inset off one side and paints its own
// surface, so neither is a transparent edge-spanning layer. Both hang off
// `PresenceGate`, so interaction releases in the same commit that starts the
// exit rather than when it ends.
return (
<AnimatePresence>
{open ? (
<PresenceGate key="backdrop">
{({ gate }) => (
<motion.button
type="button"
aria-label="Close"
tabIndex={dismissable ? 0 : -1}
onClick={() => dismissable && onOpenChange(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
{...gate}
className={cn(
"fixed inset-0 z-50 h-full w-full cursor-default bg-black/40 backdrop-blur-sm",
backdropClassName,
)}
/>
)}
</PresenceGate>
) : null}
{open ? (
<PresenceGate key="panel">
{({ gate }) => (
<motion.aside
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
initial={reduce ? { opacity: 0 } : { x: offscreen }}
animate={reduce ? { opacity: 1 } : { x: 0 }}
exit={reduce ? { opacity: 0 } : { x: offscreen }}
transition={
reduce ? { duration: 0.2, ease: EASE_OUT } : SPRING_PANEL
}
{...gate}
className={cn(
"fixed inset-y-0 z-50 flex w-80 max-w-[85vw] flex-col bg-background shadow-2xl",
side === "right"
? "right-0 border-l border-border"
: "left-0 border-r border-border",
className,
)}
>
{children}
</motion.aside>
)}
</PresenceGate>
) : null}
</AnimatePresence>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { useIsPresent } from "motion/react";
import type { ReactNode } from "react";
export interface PresenceGateRenderProps {
/**
* False from the render that starts the exit animation onward. An overlay
* kept in the tree by `AnimatePresence` is still the topmost thing on the
* page, so anything it decides from `open` alone stays true for the whole
* exit — this is the boolean that already knows the overlay is leaving.
*/
isPresent: boolean;
/**
* Spread onto every layer that takes pointer events while the overlay is
* open. Interaction releases in the same commit that starts the exit while
* the visual exit keeps playing: pointer events stop landing, and `inert`
* drops the subtree from focus order, from tab order and from the
* accessibility tree — an exiting dialog is not a dialog you can still type
* into. A layer that never takes pointer events (a wrapper that only centres
* the panel) takes `inert={!isPresent}` alone, so its own
* `pointer-events-none` is not overwritten.
*/
gate: {
inert: boolean;
style: { pointerEvents: "auto" | "none" };
};
}
export interface PresenceGateProps {
children: (props: PresenceGateRenderProps) => ReactNode;
}
/**
* Reads the presence of the subtree it renders and hands it down.
*
* `useIsPresent` only answers inside the `AnimatePresence` subtree, and the
* components that own an overlay render the `AnimatePresence` themselves, so
* the boolean has to be read one component further down: this is that
* component, and the render prop is how it reaches the layers.
*/
export function PresenceGate({ children }: PresenceGateProps) {
const isPresent = useIsPresent();
return children({
isPresent,
gate: {
inert: !isPresent,
style: { pointerEvents: isPresent ? "auto" : "none" },
},
});
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/drawer
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, type ReactNode } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { PresenceGate } from "@/lib/presence-gate";
import { cn } from "@/lib/utils";
export interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: "left" | "right";
children: ReactNode;
/** Class for the panel surface. */
className?: string;
/** Class for the backdrop. */
backdropClassName?: string;
ariaLabel?: string;
/** Close when the backdrop is clicked. Default true. */
dismissable?: boolean;
}
export function Drawer({
open,
onOpenChange,
side = "right",
children,
className,
backdropClassName,
ariaLabel,
dismissable = true,
}: DrawerProps) {
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onOpenChange]);
const offscreen = side === "right" ? "100%" : "-100%";
// Two fixed siblings, no wrapper: the backdrop spans the viewport edges but
// paints the scrim, and the panel is inset off one side and paints its own
// surface, so neither is a transparent edge-spanning layer. Both hang off
// `PresenceGate`, so interaction releases in the same commit that starts the
// exit rather than when it ends.
return (
<AnimatePresence>
{open ? (
<PresenceGate key="backdrop">
{({ gate }) => (
<motion.button
type="button"
aria-label="Close"
tabIndex={dismissable ? 0 : -1}
onClick={() => dismissable && onOpenChange(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
{...gate}
className={cn(
"fixed inset-0 z-50 h-full w-full cursor-default bg-black/40 backdrop-blur-sm",
backdropClassName,
)}
/>
)}
</PresenceGate>
) : null}
{open ? (
<PresenceGate key="panel">
{({ gate }) => (
<motion.aside
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
initial={reduce ? { opacity: 0 } : { x: offscreen }}
animate={reduce ? { opacity: 1 } : { x: 0 }}
exit={reduce ? { opacity: 0 } : { x: offscreen }}
transition={
reduce ? { duration: 0.2, ease: EASE_OUT } : SPRING_PANEL
}
{...gate}
className={cn(
"fixed inset-y-0 z-50 flex w-80 max-w-[85vw] flex-col bg-background shadow-2xl",
side === "right"
? "right-0 border-l border-border"
: "left-0 border-r border-border",
className,
)}
>
{children}
</motion.aside>
)}
</PresenceGate>
) : null}
</AnimatePresence>
);
}
API Reference
openboolean—onOpenChange(open: boolean) => void—side?"left" | "right"rightclassName?stringClass for the panel surface.
—backdropClassName?stringClass for the backdrop.
—ariaLabel?string—dismissable?booleanClose when the backdrop is clicked. Default true.
trueRelated components
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter with a liquid neck that stretches and pinches, then crisp content fades in on top. The Morph variant reveals its full-size panel from the corner nearest the trigger on one synchronized spring clip. Click or hover trigger, controlled or uncontrolled.
Morphing Modal
Family-app-style modal. A single panel that morphs its height as you navigate between inner views, with blur cross-fade on content.
Tooltip
Composable hover, focus and tap tooltip with a subtle scale and fade, collision-aware side and alignment, an optional arrow, a pointer corridor onto hoverable content, one label at a time, keyboard-only focus reveal and optional cursor tracking.
Updated