me .blog.post

Building a drawer without a library

Published on - Blog Archive

Modern HTML features like <dialog>, popover, and invoker commands let us build common UI patterns with less code. One of those patterns is the Drawer, a popular mobile UI pattern where an expandable, often modal panel slides in from one of the edges of the screen to reveal additional contents like navigation or configuration options. Libraries like shadcn and Base UI implement this pattern from scratch.

In this experiment, I will use native HTML features, modern CSS, and a minimal amount of JavaScript to create a basic implementation of the drawer.

The case for native <dialog>

The <dialog> element handles several things that custom UIs have to manually implement: backdrop rendering, focus trapping, and returning focus on close. The Escape key dismisses the dialog, and closedby="any" closes it when you click the backdrop.

Native dialogs also sit on their own top layer above the document body. This eliminates the need for portals in web frameworks like React.

Finally, invoker commands let you open the dialog with just HTML, avoiding nested components or extra JavaScript.

The drawer

Here’s the drawer demo. If your browser doesn’t support some of these APIs, there’s a video below:

Bottom-up drawer closed by dragging down its top anchor.

Design choices

Some design decisions I made to keep the experiment simple:

  • You can only dismiss the drawer by dragging the handle at the top. This is less intuitive than mobile drawers, but keeps the content interactive and accessible.
  • The drawer closes when dragged down more than 50% of its height.

Technical implementation

Notable Web APIs I used (some of them might not be supported in all major browser at the time of writing):

Dragging with Pointer Capture

A few notes on pointer capture: setPointerCapture routes all pointer events to the anchor, even if your cursor or finger leaves it. This way you don’t need to attach a pointermove listener to the document:

anchor.addEventListener('pointerdown', (e) => {
  anchor.setPointerCapture(e.pointerId);
  // ...
});

On release, lostpointercapture fires and we release the explicit capture:

anchor.addEventListener('lostpointercapture', (e) => {
  anchor.releasePointerCapture(e.pointerId);
});

The pointer release phase

Previously, I mentioned that the drawer closes when dragged down more than 50% of its height. Initially, I implement the calculations in the lostpointercapture event handler, but I soon realized that in Safari, event.clientY is always 0. A safer solution was to use pointerup instead.

anchor.addEventListener('pointerup', (e) => {
  // e.clientY is !== 0 here
});

The transition timing change

One last detail: when opening or closing normally, the drawer uses a 300ms transition with custom easing. But during a drag interaction, the drawer needs to feel connected to the pointer. I handle this by adding a data-drag attribute to the drawer and adjusting the CSS:

#drawer[data-drag] {
  transition-duration: 0ms;
}

Experiment results

Overall, I’m satisfied with the outcome of this quick experiment experiment.

From a broader point of view, I think that dialog, popover and custom selects are a good alternative to a custom implementation, with a smaller bundle size and a better userland code experience.

Since most of these features are still relatively young, they may still fall short on the accessibility side. The suggestion is, as always, to run your usability and accessibility tests before shipping to production.