zxkit

@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.

bun add @zxkit/surfaceDocumentationnpm

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

Basic

Dialog only, no responsive wrapper.

Sheet

Sheet only, this doesn't work with dynamic modals.

Dynamic

Responsive

Dialog on desktop, Drawer on mobile.

Async

Promise API

Await the modal result like a promise.

Replace

Flow API

Swap the current modal for the next step in the same flow.

Persistence with useState

usePreservedState

Custom state preserved across transitions.

Persistence with react-hook-form

usePreservedForm

Forms with validation and automatic persistence.