Overlays

Drawer Dialog

One responsive modal API: a centered dialog on desktop, a bottom sheet on mobile, switched by an SSR-safe media query.

Install

npx shadcn@latest add @paragon/drawer-dialog

Also installs: dialog, sheet

drawer-dialog.tsx

"use client";

import * as React from "react";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  type DialogContentProps,
} from "@/registry/paragon/ui/dialog";
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/registry/paragon/ui/sheet";

/*
 * Responsive modal: a centered dialog on desktop, a bottom sheet on
 * touch-sized viewports — one API for both. The media query runs through
 * useSyncExternalStore so it is SSR-safe (server renders the dialog) and
 * updates live on resize. Both variants sit on Radix Dialog, so
 * trigger/close/focus semantics are identical.
 */

function useIsDesktop(query: string) {
  const subscribe = React.useCallback(
    (onStoreChange: () => void) => {
      const mql = window.matchMedia(query);
      mql.addEventListener("change", onStoreChange);
      return () => mql.removeEventListener("change", onStoreChange);
    },
    [query],
  );
  return React.useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => true,
  );
}

const DrawerDialogContext = React.createContext(true);

export interface DrawerDialogProps
  extends React.ComponentProps<typeof Dialog> {
  /** Media query that selects the dialog presentation. */
  breakpoint?: string;
}

function DrawerDialog({
  breakpoint = "(min-width: 640px)",
  ...props
}: DrawerDialogProps) {
  const isDesktop = useIsDesktop(breakpoint);
  const Root = isDesktop ? Dialog : Sheet;
  return (
    <DrawerDialogContext.Provider value={isDesktop}>
      <Root data-slot="drawer-dialog" {...props} />
    </DrawerDialogContext.Provider>
  );
}

function DrawerDialogTrigger(
  props: React.ComponentProps<typeof DialogTrigger>,
) {
  const isDesktop = React.useContext(DrawerDialogContext);
  const Trigger = isDesktop ? DialogTrigger : SheetTrigger;
  return <Trigger data-slot="drawer-dialog-trigger" {...props} />;
}

function DrawerDialogClose(props: React.ComponentProps<typeof DialogClose>) {
  const isDesktop = React.useContext(DrawerDialogContext);
  const Close = isDesktop ? DialogClose : SheetClose;
  return <Close data-slot="drawer-dialog-close" {...props} />;
}

function DrawerDialogContent({
  overlayBlur,
  showClose,
  ...props
}: DialogContentProps) {
  const isDesktop = React.useContext(DrawerDialogContext);
  return isDesktop ? (
    <DialogContent overlayBlur={overlayBlur} showClose={showClose} {...props} />
  ) : (
    <SheetContent side="bottom" showClose={showClose} {...props} />
  );
}

function DrawerDialogHeader(props: React.ComponentProps<"div">) {
  const isDesktop = React.useContext(DrawerDialogContext);
  const Header = isDesktop ? DialogHeader : SheetHeader;
  return <Header data-slot="drawer-dialog-header" {...props} />;
}

function DrawerDialogFooter(props: React.ComponentProps<"div">) {
  const isDesktop = React.useContext(DrawerDialogContext);
  const Footer = isDesktop ? DialogFooter : SheetFooter;
  return <Footer data-slot="drawer-dialog-footer" {...props} />;
}

function DrawerDialogTitle(props: React.ComponentProps<typeof DialogTitle>) {
  const isDesktop = React.useContext(DrawerDialogContext);
  const Title = isDesktop ? DialogTitle : SheetTitle;
  return <Title data-slot="drawer-dialog-title" {...props} />;
}

function DrawerDialogDescription(
  props: React.ComponentProps<typeof DialogDescription>,
) {
  const isDesktop = React.useContext(DrawerDialogContext);
  const Description = isDesktop ? DialogDescription : SheetDescription;
  return <Description data-slot="drawer-dialog-description" {...props} />;
}

export {
  DrawerDialog,
  DrawerDialogTrigger,
  DrawerDialogClose,
  DrawerDialogContent,
  DrawerDialogHeader,
  DrawerDialogFooter,
  DrawerDialogTitle,
  DrawerDialogDescription,
};