Using Composition and ShadcnUI to build responsive dialogs and sheets.
Change the to mobile viewport width to change modal
Most commonly used modals are the dialogs. Anything to display floating content on top of screens, dialogs. But… there’s just one issue.
A dialog centered on a phone screen is a bad experience. The tap targets sit far from your thumb, the keyboard shoves it around, and swipe-to-dismiss does nothing. So let’s use drawer right? But…
A bottom drawer on a 34-inch monitor (or any desktop screen) is just as wrong in the other direction. The accepted answer is: Dialog on desktop, Drawer on mobile.
What is already out there
I came across Shadcn’s method to build responsive modals using useMediaQuery ternary. It works, and for a single modal it is fine, but look at what it does to the content.
function ShareModal() { const isDesktop = useMediaQuery("(min-width: 768px)"); if (isDesktop) { return ( <Dialog> <DialogTrigger asChild> <Button>Share</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Share this post</DialogTitle> <DialogDescription>Anyone with the link can view.</DialogDescription> </DialogHeader> <DialogFooter>{/* buttons */}</DialogFooter> </DialogContent> </Dialog> ); } return ( <Drawer> <DrawerTrigger asChild> <Button>Share</Button> </DrawerTrigger> <DrawerContent> <DrawerHeader> <DrawerTitle>Share this post</DrawerTitle> <DrawerDescription>Anyone with the link can view.</DrawerDescription> </DrawerHeader> <DrawerFooter>{/* the same buttons */}</DrawerFooter> </DrawerContent> </Drawer> );}
Every string, every button, every class appears twice. Change the description of the dialog, forget that of the drawer, and the desktop and mobile views now say different things. Multiply by every modal in the app. Not looking so good. The duplication is not in the container, it is in the content, and the content is exactly the part that changes most often.
The instinctive fix is a boolean prop (<Modal asDrawer />) or a variant string, but that just moves the branching inside and grows a prop for every future difference. Composition handles this better: keep the children stable, swap what renders them.
Solution
Using Composition, we can resolve this DRY violation. Building a ResponsiveModal that takes the content once and swaps the container underneath it, Shadcn's Dialog and Drawer, a component map in context, and one media query hook.
<ResponsiveModal> <ResponsiveModal.Trigger asChild> <Button size="lg">Open example</Button> </ResponsiveModal.Trigger> <ResponsiveModal.Content className="md:w-[min(32rem,calc(100%-3rem))]"> <ResponsiveModal.Header> <ResponsiveModal.Title>Build the modal once.</ResponsiveModal.Title> <ResponsiveModal.Description> Keep the content composition stable and let the container switch between dialog and drawer based on viewport width. </ResponsiveModal.Description> </ResponsiveModal.Header> <ResponsiveModal.Footer className="sm:justify-end"> <ResponsiveModal.Close asChild> <Button variant="outline">Cancel</Button> </ResponsiveModal.Close> <Button>Continue</Button> </ResponsiveModal.Footer> </ResponsiveModal.Content></ResponsiveModal>
Similar to Shadcn’s compound API, right? So no re-inventing the wheel.
Step 1: Swap the container at the root
The first move is easy. One component owns the media query and picks the outer primitive:
Quick note on useMediaQuery before we move on. It wraps the browser's matchMedia, and the browser only pings us when the answer actually flips. No resize listeners, no width math running on every frame. One quirk though: the server has no viewport. So on the server, and for the very first client render, the hook returns null. Not true, not false, just “no idea yet”. Keep that null in mind, it shows up again in Step 2.
That root swap fixes the outermost layer and nothing else. The children still contain DialogTitle or DrawerTitle, and those are not interchangeable: DialogTitle renders into Dialog's accessibility tree, DrawerTitle into vaul's. Nesting a DialogHeader inside a Drawer gives you broken semantics and broken styles. The children need to change with the container, and the children belong to the caller. That is the actual problem.
The caller writes the composition once, as children. The root receives those children as an opaque, already-built tree; it cannot reach in and rewrite DialogTitle to DrawerTitle, and cloning children to patch them is the kind of trick that breaks the moment someone wraps a title in a div. So the pieces inside the tree have to adapt themselves. For that they need one piece of information the root has and they do not: which mode we are in.
How does that information travel? Props are out. The root does not render the subcomponents, the caller does, so the root has no prop to pass. Making the caller thread isDesktop into every piece by hand would reintroduce exactly the noise we are removing (Uncle Bob might just arrest us). A module-level variable would leak across instances and break the moment two modals disagree.
This is the situation React context exists for: a value that a parent owns and any descendant can read, flowing under the caller's JSX without appearing in it. Pair it with dot-notation subcomponents (ResponsiveModal.Title, ResponsiveModal.Close) and we get the compound component pattern. The root is the brain: it measures, decides, and provides. The subcomponents are limbs: each one reads the shared context and renders accordingly. The caller's composition in between stays completely generic. Three layers, one secret channel from the first to the second.
Building the channel. Two pieces: the context itself, and a hook the subcomponents use to read it.
// context.tsx, first passimport { createContext, use } from "react";interface ResponsiveModalContextValue { isDesktop: boolean | null;}export const ResponsiveModalContext = createContext<ResponsiveModalContextValue | null>(null);export function useResponsiveModal() { const context = use(ResponsiveModalContext); if (!context) { throw new Error( "ResponsiveModal compound components must be used within ResponsiveModal.Root", ); } return context;}
The default value is null, and that is a deliberate choice, not a shrug. use() only returns the default when a component reads the context without a provider above it, and for this component that is not a configuration, it is a bug: someone rendered ResponsiveModal.Title outside a ResponsiveModal. Rather than let null leak out and crash three layers deeper with an unhelpful stack trace, useResponsiveModal converts it into an error message that names the fix. Every subcomponent gets that guard for free by going through the hook instead of touching the context directly.
Two React 19 notes on the syntax. Reading happens through use(ResponsiveModalContext) rather than useContext. And providing happens by rendering the context object itself, <ResponsiveModalContext value={...}>, with no .Provider suffix. Older React needs useContext and <ResponsiveModalContext.Provider>; nothing else about the pattern changes.
The root wraps its output in the provider, and now the question is what the context should actually carry. The first pass above says: the boolean. That instinct is worth following to its end, because where it falls short is exactly what motivates the real design.
Step 2: Put components in context
The first instinct is to share the boolean itself, with the first-pass context, the root provides the boolean and every subcomponent branches on it:
It works, but count what it costs as the component grows. Title, Description, Header, Footer, Close: five subcomponents, five copies of the same isDesktop ? conditional, all re-deriving a decision the root already made. Add a ResponsiveModal.Handle next month and you write the sixth. Every conditional is a site where one subcomponent can fall out of step with the others.
The stronger move is a dispatch table. Resolve the decision once at the root into a map of concrete components, and share the map:
Now every subcomponent collapses from a conditional into an O(1) property read:
function ResponsiveModalTitle({ children, className }) { const { Title } = useResponsiveModal(); return <Title className={className}>{children}</Title>;}
ResponsiveModal.Title has no idea whether it renders DialogTitle or DrawerTitle, and it never needs to find out. This is the classic replace-conditional-with-lookup move, and the win is less about CPU (a ternary is cheap) and more about the shape of change: one decision site instead of N, one line per map to add a new part, and no possible state where the Header thinks it is in a dialog while the Footer thinks it is in a drawer.
The map still carries isDesktop alongside the components, and that is not redundancy. The lookup handles the pieces that differ only by name. Trigger and Content differ by structure (different props, different wrappers), so they still need the boolean to branch on. The context serves both kinds of consumer from one value.
The root now resolves the map, handles the pre-hydration null from Step 1, and provides:
In the null branch, before the first client measurement, no primitive mounts at all. The trigger renders as a plain span so the layout does not jump, Content renders nothing, and one render later the real answer arrives. That branch allocates its context value inline, and that is fine: it runs exactly once per mount, before hydration completes, so there are no repeated invalidations to worry about.
Attach the pieces with Object.assign and the compound API is done:
And since every subcomponent reads through useResponsiveModal, render ResponsiveModal.Title outside a ResponsiveModal and it fails loudly at development time instead of rendering half a modal.
One gotcha. Cross the breakpoint while the modal is open and… it closes. React sees Dialog swap to Drawer, treats it as a different component, and unmounts the whole tree, open state included. If your modal needs to survive a resize, control it:
Oh, and always render a ResponsiveModal.Title. Both primitives use it as the accessible name for screen readers. No visible title in the design? Wrap it in an sr-only span, don't skip it.
Trade-offs
What this buys:
The content exists once. Copy edits, new fields, and reordered buttons happen in one place and both viewports stay in sync by construction.
The API mirrors shadcn's Dialog, so there is nothing new to learn at call sites.
Subcomponents are implementation-blind. Swapping the mobile container from Drawer to something else touches context.tsx and the root, not the callers.
No boolean prop proliferation. There is no asDrawer, no variant="sheet", no matrix of flags to test.
Controlled mode and a custom breakpoint fall out of the root's props for free.
What it costs:
Both primitives ship. Dialog and vaul's Drawer are both in the client bundle regardless of viewport, because the decision happens at runtime in the browser. For two components already used elsewhere in the app the marginal cost is near zero, but it is not zero.
ElementType in the context erases prop types. Subcomponents pass className and children only, so primitive-specific features (drawer snap points, dialog positioning) are unreachable without widening the wrapper deliberately.
It is a shim over two upstream APIs. Dialog's trigger wants a render prop, vaul's wants asChild; the Trigger absorbs that, but every upstream change lands in the wrapper.
The unmount-on-resize behavior surprises people the first time (see the gotcha above).
When is this overkill?
If the app has exactly one responsive modal and it will stay that way, the Shadcn docs ternary is fewer lines and one less abstraction to maintain. Composition is an investment that pays out at the second call site and keeps paying at every one after that. Build the ternary first, feel the duplication, then reach for this. This is easily transferable to making responsive sheets.
The full component, hook included, lives in this gist. Drop it next to your shadcn dialog and drawer and compose away.