@zxkit/surface
Responsive dialogs and drawers with a modal stack.
Bring your own primitives — Base UI, Radix, or anything with an open/onOpenChange root. Define your modals once, push them by name from anywhere, and let the breakpoint decide how they render — without losing state in the swap.
Define once
modals/index.ts
One typed registry. Push any modal from anywhere by name, with typed props.
import { createPushModal, modal } from '@zxkit/surface'
import { Dialog } from '@zxkit/ui/dialog'
import { DynamicWrapper } from './dynamic'
export const { pushModal, pushModalAsync, popModal, ModalProvider } =
createPushModal({
// any primitive works — Base UI, React Aria, Radix, your own
defaultWrapper: Dialog,
modals: {
EditOrder: modal<{ orderId: string }>(EditOrderModal),
ConfirmDelete: modal<Record<never, never>, boolean>({
Wrapper: DynamicWrapper,
Component: ConfirmDeleteModal,
}),
},
})Responsive
modals/dynamic.tsx
Wrap shadcn Dialog and Drawer once. Below the breakpoint the same modal becomes a drawer.
import { createResponsiveWrapper } from '@zxkit/surface'
import { createPreservedForm } from '@zxkit/surface/react-hook-form'
import { Dialog, DialogContent } from '@zxkit/ui/dialog'
import { Drawer, DrawerContent } from '@zxkit/ui/drawer'
export const { Wrapper, Content, usePreservedState, usePreservedStore } =
createResponsiveWrapper({
desktop: { Wrapper: Dialog, Content: DialogContent },
mobile: { Wrapper: Drawer, Content: DrawerContent },
breakpoint: 640,
})
// opt in only if you use react-hook-form
export const usePreservedForm = createPreservedForm(usePreservedStore)
export { Wrapper as DynamicWrapper }Use
anywhere.tsx
Push with props, or await an async modal and get a typed result back.
pushModal('EditOrder', { orderId })
const confirmed = await pushModalAsync('ConfirmDelete')
if (confirmed) {
await deleteOrder(orderId)
}- Primitive-agnostic
- No dialog library is bundled or assumed. Pass Base UI, Radix, or your own components in.
- Responsive by breakpoint
- The same modal renders as a Dialog on desktop and a Drawer on mobile.
- Preserved state
- usePreservedState and usePreservedForm survive the Dialog ↔ Drawer swap.
- One dependency
- mitt. React is the only required peer — react-hook-form lives behind a subpath.
- Modal stack
- Push, replace, and pop modals with a router-like flow.
- Async modals
- await pushModalAsync and resolve a typed result from inside the modal.
- Event-driven
- React to opens and closes with onPushModal and useOnPushModal.
Examples
Try them live.
Resize the window to see the switch between Dialog and Drawer.
Radix and Base UI, same API
AgnosticTwo independent stacks on this page. surface depends on neither.
Resize past the breakpoint with a responsive one open — both swap to a drawer and keep their input.
Default
BasicDialog only, no responsive wrapper.
Sheet
Sheet only, this doesn't work with dynamic modals.
Dynamic
ResponsiveDialog on desktop, Drawer on mobile.
Async
Promise APIAwait the modal result like a promise.
Replace
Flow APISwap the current modal for the next step in the same flow.
Flow
flowMulti-step in one shell. Compare the two — replace flashes, flow does not.
Watch the panel, not the content. Replace tears the panel down and mounts a new one, so it replays its open animation. A flow keeps the same panel and swaps only the body.
Persistence with useState
usePreservedStateCustom state preserved across transitions.
Persistence with react-hook-form
usePreservedFormForms with validation and automatic persistence.