Responsive Dialog
A centered dialog on desktop and a bottom drawer on mobile, composed from Dialog and Drawer, not a separate component.
Usage
A composition of dialog and drawer: a useIsDesktop hook renders
Dialog at 640px and up (Tailwind's sm
breakpoint) and Drawer below it. Copy the hook
and the branch into your app.
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from '@epilot/spark-ui/dialog'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
} from '@epilot/spark-ui/drawer'
import { useState, useSyncExternalStore } from 'react'const DESKTOP_MEDIA_QUERY = '(min-width: 640px)'
const subscribe = (onChange: () => void) => {
const mediaQueryList = window.matchMedia(DESKTOP_MEDIA_QUERY)
mediaQueryList.addEventListener('change', onChange)
return () => mediaQueryList.removeEventListener('change', onChange)
}
function useIsDesktop() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(DESKTOP_MEDIA_QUERY).matches,
() => true,
)
}function EditSomething() {
const isDesktop = useIsDesktop()
const [open, setOpen] = useState(false)
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button>Open</Button>} />
<DialogPortal>
<DialogOverlay />
<DialogContent>
<DialogClose aria-label="Close" />
<DialogHeader>
<DialogTitle>Title</DialogTitle>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
)
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button>Open</Button>
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Title</DrawerTitle>
</DrawerHeader>
<DrawerFooter className="px-6 pb-6">
<DrawerClose asChild>
<Button>Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</DrawerPortal>
</Drawer>
)
}Keep open in your own state as above: crossing 640px swaps the underlying
component, and shared state is what keeps the overlay open through a resize.
Content placed between header and footer pads itself with px-6 sm:px-0: the
dialog pads its body, the drawer does not.
Iframe content
An iframe reloads whenever its DOM parent changes, so swapping shells mid-flow
would lose everything typed inside it. Render it once with
Reparent and place a ReparentSlot in each shell:
the iframe moves between dialog and drawer without reloading. Call
handle.park() before the swap (in the media-query listener and on close),
and where the browser cannot move DOM state (supportsMoveBefore is false,
Safari) lock the shell while open. Type into the field below, resize across
640px, and the value survives the dialog/drawer swap.