Create a React Drawer Component using the native HTML Dialog

Thursday, 19 June 2025

This isn’t going to be a long post, but I wanted to share a quick exploration I’ve been doing with the native HTML Dialog Element and how we can use it to create a React Drawer Component.

The native HTML Dialog Element

The native HTML Dialog Element is a new HTML element that allows us to create a modal dialog. In it’s simplest form, it looks like this:

<dialog>
  <p>Greetings, one and all!</p>
  <form method="dialog">
    <button>OK</button>
  </form>
</dialog>

Using the form element with the method="dialog" attribute, we can close the dialog without any JavaScript.

Some nice features we get for free when using the Dialog element:

  • The dialog can be modal, meaning that the user cannot interact with the rest of the page until the dialog is closed.
  • We can dismiss the dialog by clicking the Escape key.
  • The dialog is added to the top layer of the DOM, meaning it will be displayed on top of other elements. (no more setting z-index: 9999999)
  • We have access to the :backdrop pseudo-element, which allows us to style the background of the dialog.

The React Drawer Component

I’ll add some comments to the code below to give a better idea of what’s going on, but the code should be pretty self explanatory. I’ve also aimed to keep it as simple as possible so if you want to build on top of it, you should be able to do so without too much trouble.

For the @/hooks/use-controllable-state hook, I looked at how Radix UI handles thier controlled/uncontrolled state management and pulled in their hook from here

The cn function is a utility function that I use to merge class names together. I’ve pulled it from shadcn/ui

"use client";

import { useControllableState } from "@/hooks/use-controllable-state";
import { cn } from "@/lib/utils";
import { X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";

type DialogProps = {
  children?: React.ReactNode;
  title?: string;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  trigger?: React.ReactNode;
  className?: string;
};

// these classNames are applied when the open attribute is not set
const defaultClassNames = "not-open:pointer-events-none not-open:opacity-0";

// tweak these to your liking
const VELOCITY_THRESHOLD = 0.4;
const CLOSE_THRESHOLD = 0.5;

export const Drawer = ({
  children,
  title,
  open: openProp,
  onOpenChange,
  trigger,
  className,
}: DialogProps) => {
  // the ref to our dialog element
  const dialogRef = useRef<HTMLDialogElement>(null);

  // our single soure of truth for our controlled/uncontrolled open/close drawer state
  const [isOpen, setIsOpen] = useControllableState({
    prop: openProp,
    defaultProp: false,
    onChange: onOpenChange,
    caller: "Drawer",
  });

  // our state variables
  const [isDragging, setIsDragging] = useState(false);
  const [currentY, setCurrentY] = useState(0);
  const [isClosing, setIsClosing] = useState(false);

  // refs for storing values that we need to persist across renders
  const dragStartTime = useRef<Date | null>(null);
  const dragEndTime = useRef<Date | null>(null);
  const touchStartY = useRef(0);

  // show/hide the dialog based on our state
  useEffect(() => {
    if (isOpen) {
      dialogRef.current?.showModal();
    } else {
      dialogRef.current?.close();
    }
  }, [isOpen]);

  // handle starting to close the drawer
  const handleClose = useCallback(() => {
    if (dialogRef.current) {
      setIsClosing(true);
    }
  }, []);

  // reset the drawer to its initial state
  const resetDrawerPosition = useCallback(() => {
    setCurrentY(0);
  }, []);

  const onPointerStart = useCallback((e: React.PointerEvent) => {
    if (
      e.target instanceof HTMLElement &&
      e.target.closest("[data-drag-handle]")
    ) {
      setIsDragging(true);
      setCurrentY(0);
      touchStartY.current = e.clientY;
      dragStartTime.current = new Date();
      // https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture
      e.currentTarget.setPointerCapture(e.pointerId);
    }
  }, []);

  const onPointerMove = useCallback(
    (e: React.PointerEvent) => {
      if (e.pointerType === "touch") {
        e.preventDefault(); // Prevent scrolling while dragging on touch devices
      }
      if (!isDragging) return;

      // This updates the drawers y position
      const deltaY = e.clientY - touchStartY.current;
      setCurrentY(Math.max(0, deltaY));
    },
    [isDragging]
  );

  const onPointerEnd = useCallback(
    (e: React.PointerEvent) => {
      if (!isDragging || !dialogRef.current) return;
      setIsDragging(false);
      dragEndTime.current = new Date();

      if (!dragStartTime.current) return;

      const timeTaken =
        dragEndTime.current.getTime() - dragStartTime.current.getTime();
      const distMoved = touchStartY.current - e.clientY;
      const velocity = Math.abs(distMoved) / timeTaken;

      //   Moved upwards, don't do anything
      if (distMoved > 0) {
        resetDrawerPosition();
        return;
      }

      if (velocity > VELOCITY_THRESHOLD) {
        handleClose();
        return;
      }

      const visibleDrawerHeight = Math.min(
        dialogRef.current.getBoundingClientRect().height ?? 0,
        window.innerHeight
      );

      // If the drawer is moved more than the CLOSE_THRESHOLD, close it
      if (Math.abs(currentY) >= visibleDrawerHeight * CLOSE_THRESHOLD) {
        handleClose();
        return;
      }

      // Reset the drawer to its initial state
      resetDrawerPosition();
    },
    [currentY, handleClose, isDragging, resetDrawerPosition]
  );

  const handleOpen = useCallback(() => {
    setIsOpen(true);
  }, [setIsOpen]);

  const lightDismiss = useCallback(
    (e: MouseEvent) => {
      const dialog = e.target as HTMLDialogElement;

      // Don't dismiss if we just finished dragging (within last 100ms)
      // without this the drag event end triggers this function, closing the drawer
      if (
        dragEndTime.current &&
        Date.now() - dragEndTime.current.getTime() < 100
      ) {
        return;
      }

      if (dialog.nodeName === "DIALOG") {
        handleClose();
      }
    },
    [handleClose]
  );

  // tap into the drawers events
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    // we can use an AbortController to better handle cleanup
    const controller = new AbortController();
    const { signal } = controller;

    dialog.addEventListener("click", lightDismiss, { signal });

    dialog.addEventListener(
      "cancel",
      (e) => {
        if (!signal.aborted) {
          e.preventDefault();
          handleClose();
        }
      },
      { signal }
    );

    return () => controller.abort();
  }, [handleClose, lightDismiss, setIsOpen]);

  return (
    <>
      {trigger && <div onClick={handleOpen}>{trigger}</div>}

      <dialog
        ref={dialogRef}
        className={cn(
          defaultClassNames,
          "fixed inset-0 mt-auto mx-auto w-full max-w---drawer-max-width flex flex-col transition-transform duration-300 ease---ease-out-quint rounded-4xl max-sm:rounded-b-none shadow-lg bg-background text-foreground mb---bottom-margin min-h-[80vh] [--bottom-margin:0px] sm:[--bottom-margin:--spacing(4)]",
          isDragging && "transition-none select-none",
          className
        )}
        style={
          {
            transform: isClosing
              ? "translateY(100%)"
              : isOpen
              ? `translateY(calc(${currentY}px`
              : "translateY(100%)",
          } as React.CSSProperties
        }
        onPointerDown={onPointerStart}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerEnd}
        onPointerCancel={onPointerEnd}
        onTransitionEnd={() => {
          if (isClosing) {
            dialogRef.current?.close();
            setIsOpen(false);
            setIsClosing(false);
            setCurrentY(0);
          }
        }}
        inert={isOpen ? false : true}
      >
        <div className="p-4 flex flex-col grow gap-2 relative">
          <header className="flex flex-col gap-1">
            <div
              data-drag-handle
              className="h-1 rounded-full w-4/10 bg-black/10 mx-auto my-4 absolute top-0 left-1/2 -translate-x-1/2 cursor-grab active:cursor-grabbing touch-none"
            >
              <div className="absolute left-0 right-0 -inset-y-6" />
            </div>
            <div className="flex items-center justify-between">
              {title && (
                <h2 className="text-lg font-semibold text-zinc-900">{title}</h2>
              )}

              <button
                className="ml-auto rounded-full bg-zinc-900/10 flex items-center justify-center size-9 hover:bg-zinc-900/15"
                onClick={handleClose}
              >
                <X className="size-4" />
              </button>
            </div>
          </header>
          {children}
        </div>
      </dialog>
    </>
  );
};

And here’s a working example:

Drawer Example

This is an example of a drawer component in React using the native HTML Dialog element.

Some things to notice here:

  • We are using Tailwind 4, so you might see some new syntax in there which is slightly unfamiliar (this Astro blog doesnt use tailwind 4 so I have adjusted the rendered example accordingly)
  • In this implementation we aren’t rendering the drawer on the fly, we are using the inert attribute and opacity to hide it until the drawer is opened.
  • The dialog html element in general relies on JavaScript to open and close it, see the useEffect hook above. The dialog does have an open attribute but it doesn’t open the dialog in a modal way.
  • We are using both distance and velocity as a metric to determine if the drawer should be closed.
  • It’s a super simple implementation, we don’t handle snap points or any more advances features.

Some additional thoughts:

  • I wonder how far we could get using the popover API
  • For the amount of code and features we have it feels good, but I’m wondering what improvements we could make

Feel free to copy the Drawer yourself, have a play around with it and let me know what you think/if you have any improvements.