Most drawer components you find in UI libraries feel close but not quite right — the easing is off, the drag feels sticky, or focus management is missing entirely. After reading through Emil Kowalski’s breakdown of his drawer component, I decided to build my own version to really understand the moving parts.
The core structure
A drawer is really three things working together:
- An overlay that dims the background and captures outside clicks.
- A panel that slides in from an edge of the screen.
- A focus trap so keyboard users can’t tab outside the drawer while it’s open.
Here’s the minimal shell, before any animation or gesture logic:
type DrawerProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
};
export function Drawer({ open, onClose, children }: DrawerProps) {
if (!open) return null;
return (
<div className="drawer-overlay" onClick={onClose}>
<div
className="drawer-panel"
role="dialog"
aria-modal="true"
onClick={(event) => event.stopPropagation()}
>
{children}
</div>
</div>
);
}
Adding drag-to-dismiss
The part that actually makes a drawer feel native is letting the user drag it
closed. I track a translateY offset in state and update it on pointermove:
function useDragToClose(onClose: () => void) {
const [offset, setOffset] = useState(0);
const startY = useRef(0);
const dragging = useRef(false);
const onPointerDown = (event: React.PointerEvent) => {
dragging.current = true;
startY.current = event.clientY;
};
const onPointerMove = (event: React.PointerEvent) => {
if (!dragging.current) return;
const delta = event.clientY - startY.current;
setOffset(Math.max(0, delta));
};
const onPointerUp = () => {
dragging.current = false;
if (offset > 120) onClose();
else setOffset(0);
};
return { offset, onPointerDown, onPointerMove, onPointerUp };
}
The key insight is that the drag offset should drive a CSS transform directly, rather than going through a spring library on every frame. That keeps the interaction feeling attached to the user’s finger.
Styling the panel
.drawer-panel {
position: fixed;
inset: auto 0 0 0;
max-height: 90vh;
border-radius: 16px 16px 0 0;
background: white;
transform: translateY(var(--drag-offset, 0px));
transition: transform 200ms ease;
will-change: transform;
}
.drawer-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 40%);
}
What I’d still add
- Snap points, so the drawer can rest at 50% and 100% height like iOS sheets.
- Scroll locking on the body while the drawer is open.
- Nested drawers, which need their own stacking context and escape handling.
| Feature | Status |
|---|---|
| Drag to dismiss | Done |
| Focus trap | Done |
| Snap points | Planned |
| Nested drawers | Planned |
For the full write-up on gesture physics and velocity-based dismissal, Emil’s original article is the best resource I’ve found on the topic.