zxkit

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

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 { 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

Agnostic

Two 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

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.

Flow

flow

Multi-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

usePreservedState

Custom state preserved across transitions.

Persistence with react-hook-form

usePreservedForm

Forms with validation and automatic persistence.