One responsive modal API: a centered dialog on desktop, a bottom sheet on mobile, switched by an SSR-safe media query.
npx shadcn@latest add @paragon/drawer-dialogAlso installs: dialog, sheet
"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,
};