Drawer
Side panel that slides in from the left or right with a spring, backdrop blur, body scroll lock and esc-to-close.
Preview
TSXcomponents/previews/motion/drawer.preview.tsx
"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>
);
}
TSXcomponents/motion/drawer.tsx
"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. See tests/fixed-overlay-edge-sampling.test.tsx.
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.
$ bunx --bun shadcn add @beui/drawerdrawerdrawer
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/presence-gate.tsx
"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" },
},
});
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/drawer.tsx
"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. See tests/fixed-overlay-edge-sampling.test.tsx.
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
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
Hover or focus tooltip with blur enter/exit and spring spawn.
Popover
Gooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top, plus a Morph variant that clip-morphs open from the trigger corner. Click or hover trigger, controlled or uncontrolled.
Updated