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.

On this page