## Drawer — Overlay
Side panel that keeps its place.
Docs: https://www.interior.dev/docs/drawer
Reference: https://www.interior.dev/reference/drawer
License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT)
### Install
Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project.
`bunx shadcn@latest add https://www.interior.dev/r/drawer.json`
Or run `bun add motion` and copy the source below.
### Usage
```tsx
"use client";
import { useState } from "react";
import { Drawer } from "@/components/interior/drawer";
export function ResultsToolbar({ total }: { total: number }) {
const [open, setOpen] = useState(false);
const [sort, setSort] = useState("relevance");
return (
<>
setOpen(false)}>
Show results
}
>
>
);
}
```
### Props
- `open` (`boolean`)
Controlled visibility. The panel stays mounted either way; this only moves it.
- `onOpenChange` (`(open: boolean) => void`)
Fired by the close button, the scrim, Escape, and a completed dismiss drag.
- `title` (`string`)
Labels the dialog. Read once when focus enters, not on every state change.
- `children` (`React.ReactNode`)
Panel body. Scrolls inside a capped region; its scroll offset survives closing.
- `description` (`string | undefined`)
Optional second line in the header. Truncates rather than wrapping, so the header height is fixed.
- `footer` (`React.ReactNode | undefined`)
Pinned below the scroll region. Never scrolls away from the primary action.
- `side` (`"left" | "right"`) — default: `"right"`
Which edge the panel is anchored to. Sets the dismiss direction and which corners are rounded.
- `width` (`number`) — default: `320`
Panel width in pixels, clamped to calc(100% - 40px) so the scrim stays reachable on narrow screens.
- `container` (`"viewport" | "parent"`) — default: `"viewport"`
"parent" scopes the drawer to the nearest positioned ancestor and skips scroll lock; that ancestor must be relative and overflow-hidden.
- `closeLabel` (`string`) — default: `"Close panel"`
Accessible name for the icon-only close button.
- `dismissOnScrimClick` (`boolean`) — default: `true`
Set false when the panel holds unsaved input and a stray click would discard it.
- `className` (`string`) — default: `""`
Appended last to the panel, so surface and border classes can be overridden.
### Behavior notes
- Closing translates the panel, it does not unmount it, so the scroll offset inside and the state of every control it holds are still there on reopen; a drawer that unmounts hands back a list scrolled to the top and a form wiped clean.
- Focus is captured when the panel opens and returned to the exact element that opened it, so closing never drops the caret on the body and restart tabbing from the head of the document.
- While open, Tab wraps inside the panel and the rest of the document is marked inert, so nothing behind the scrim can be tabbed into or read out of order.
- Locking the page scroll pads the document by the scrollbar width it just removed, so the content behind the drawer does not jump sideways as the panel arrives.
- The dismiss drag reports its position in six discrete steps rather than a float, and releasing resumes the spring from wherever the panel currently sits, so an interrupted dismiss settles instead of snapping.
- Under prefers-reduced-motion the panel is placed at its final position without the travel, still visible, still focused, never left half open.
### Source (`components/interior/drawer.tsx`)
```tsx
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
animate,
motion,
useDragControls,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
const DISCLOSE = {
type: "spring",
stiffness: 150,
damping: 27,
mass: 1,
} as const;
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
type Inertable = HTMLElement & { inert?: boolean };
type DragInfo = {
offset: { x: number; y: number };
velocity: { x: number; y: number };
};
export type DrawerSide = "left" | "right";
export type UseDrawerOptions = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
side?: DrawerSide;
width?: number;
dismissRatio?: number;
modal?: boolean;
};
export function useDrawer({
open: controlled,
defaultOpen = false,
onOpenChange,
side = "right",
width = 320,
dismissRatio = 0.38,
modal = true,
}: UseDrawerOptions = {}) {
const [uncontrolled, setUncontrolled] = useState(defaultOpen);
const [dragging, setDragging] = useState(false);
const open = controlled ?? uncontrolled;
const sign = side === "right" ? 1 : -1;
const away = sign * (width + 24);
const x = useMotionValue(open ? 0 : away);
const veil = useTransform(x, (v) => 1 - Math.min(1, Math.abs(v) / width));
const rootRef = useRef(null);
const panelRef = useRef(null);
const returnTo = useRef(null);
const anim = useRef<{ stop: () => void } | null>(null);
const live = useRef(open);
live.current = open;
const changed = useRef(onOpenChange);
changed.current = onOpenChange;
const reduced = useReducedMotion();
const controls = useDragControls();
const setOpen = useCallback(
(next: boolean) => {
if (controlled === undefined) setUncontrolled(next);
changed.current?.(next);
},
[controlled],
);
const close = useCallback(() => setOpen(false), [setOpen]);
const glide = useCallback(
(to: number) => {
anim.current?.stop();
anim.current = animate(x, to, reduced ? { duration: 0 } : DISCLOSE);
},
[x, reduced],
);
useEffect(() => {
glide(open ? 0 : away);
return () => anim.current?.stop();
}, [open, away, glide]);
useEffect(() => {
const panel = panelRef.current as Inertable | null;
if (!panel) return;
panel.inert = !open;
return () => {
panel.inert = false;
};
}, [open]);
useEffect(() => {
if (open) {
const active = document.activeElement;
returnTo.current = active instanceof HTMLElement ? active : null;
const panel = panelRef.current;
if (!panel) return;
const first = panel.querySelector(FOCUSABLE);
(first ?? panel).focus({ preventScroll: true });
return;
}
const target = returnTo.current;
returnTo.current = null;
if (target && target.isConnected) target.focus({ preventScroll: true });
}, [open]);
useEffect(() => {
if (!modal || !open) return;
const root = document.documentElement;
const overflow = root.style.overflow;
const padding = root.style.paddingRight;
const gutter = window.innerWidth - root.clientWidth;
root.style.overflow = "hidden";
if (gutter > 0) root.style.paddingRight = `${gutter}px`;
return () => {
root.style.overflow = overflow;
root.style.paddingRight = padding;
};
}, [modal, open]);
useEffect(() => {
const shell = rootRef.current;
if (!modal || !open || !shell) return;
const muted: Inertable[] = [];
for (const node of Array.from(document.body.children)) {
if (!(node instanceof HTMLElement) || node.contains(shell)) continue;
const el = node as Inertable;
if (el.inert) continue;
el.inert = true;
muted.push(el);
}
return () => {
for (const el of muted) el.inert = false;
};
}, [modal, open]);
const onKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const panel = panelRef.current;
if (!panel) return;
if (event.key === "Escape") {
event.stopPropagation();
close();
return;
}
if (event.key !== "Tab") return;
const nodes = Array.from(panel.querySelectorAll(FOCUSABLE));
if (nodes.length === 0) {
event.preventDefault();
panel.focus({ preventScroll: true });
return;
}
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || active === panel)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
},
[close],
);
const startDrag = useCallback(
(event: React.PointerEvent) => {
if (!live.current) return;
controls.start(event);
},
[controls],
);
const onDragStart = useCallback(() => setDragging(true), []);
const onDragEnd = useCallback(
(_event: MouseEvent | TouchEvent | PointerEvent, info: DragInfo) => {
setDragging(false);
const travel = sign * info.offset.x;
const speed = sign * info.velocity.x;
if (travel > width * dismissRatio || speed > 520) {
close();
return;
}
glide(0);
},
[sign, width, dismissRatio, glide, close],
);
const panelProps = {
tabIndex: -1,
role: "dialog" as const,
"aria-modal": modal,
onKeyDown,
drag: "x" as const,
dragControls: controls,
dragListener: false,
dragMomentum: false,
dragConstraints: { left: 0, right: 0 },
dragElastic:
side === "right"
? { top: 0, bottom: 0, left: 0, right: 1 }
: { top: 0, bottom: 0, left: 1, right: 0 },
onDragStart,
onDragEnd,
};
return {
open,
side,
width,
dragging,
x,
veil,
setOpen,
close,
rootRef,
panelRef,
panelProps,
gripProps: { onPointerDown: startDrag },
};
}
export type UseDrawerResult = ReturnType;
const CLOSE_ICON = (
);
export type DrawerProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
children: React.ReactNode;
description?: string;
footer?: React.ReactNode;
side?: DrawerSide;
width?: number;
container?: "viewport" | "parent";
closeLabel?: string;
dismissOnScrimClick?: boolean;
className?: string;
};
export function Drawer({
open,
onOpenChange,
title,
children,
description,
footer,
side = "right",
width = 320,
container = "viewport",
closeLabel = "Close panel",
dismissOnScrimClick = true,
className = "",
}: DrawerProps) {
const titleId = useId();
const hintId = useId();
const drawer = useDrawer({
open,
onOpenChange,
side,
width,
modal: container === "viewport",
});
const edge =
side === "right"
? "right-0 rounded-l-[14px] border-l"
: "left-0 rounded-r-[14px] border-r";
const [host, setHost] = useState(null);
useEffect(() => {
setHost(container === "viewport" ? document.body : null);
}, [container]);
const tree = (
{title}
{description ? (
{description}
) : null}
{children}
{footer ? (
{footer}
) : null}
Press Escape to close this panel, or drag its handle toward the edge.
);
if (container !== "viewport") return tree;
return host ? createPortal(tree, host) : null;
}
```