← Writing

Building a Custom Drawer Component in React

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:

  1. An overlay that dims the background and captures outside clicks.
  2. A panel that slides in from an edge of the screen.
  3. 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

FeatureStatus
Drag to dismissDone
Focus trapDone
Snap pointsPlanned
Nested drawersPlanned

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.