@zxkit/surface
Responsive dialogs and drawers with a modal stack.
Built for shadcn Dialog and Drawer. 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 { DynamicWrapper } from './dynamic'
export const { pushModal, pushModalAsync, popModal, ModalProvider } =
createPushModal({
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 { Dialog, DialogContent } from '@zxkit/ui/dialog'
import { Drawer, DrawerContent } from '@zxkit/ui/drawer'
export const { Wrapper, Content, usePreservedState, usePreservedForm } =
createResponsiveWrapper({
desktop: { Wrapper: Dialog, Content: DialogContent },
mobile: { Wrapper: Drawer, Content: DrawerContent },
breakpoint: 640,
})
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)
}- 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.
- 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.
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.
Persistence with useState
usePreservedStateCustom state preserved across transitions.
Persistence with react-hook-form
usePreservedFormForms with validation and automatic persistence.