Surfaces
Define a responsive Dialog-or-Drawer with defineSurface where the props are a Zod schema, and open it from anywhere.
A surface is the Hub's word for a modal — a confirmation, an alert, a small create form — that pops over the page. You've already met its sibling, the side panel, which slides in from the edge. A surface is the centered version: a Dialog on desktop, a Drawer on mobile, chosen for you at render time.
The thing that makes surfaces pleasant to work with is a single decision: the props of a surface are a Zod schema. That one choice gives you defaults, optional fields, and full type-safety at every call site — with no hand-written prop types. Define the schema once, and both "what can I pass in?" and "what do I read back out?" are answered.
Every surface lives in its own *.surface.tsx file under src/components/surfaces/. A Vite codegen plugin scans those files and regenerates a barrel, so the moment you save a new file, Surface.YourThing is available everywhere — no manual registration.
The shape of a surface
Each file does two things: it defines a surface handle and exports a component that renders it. The handle comes from defineSurface(id, schema, config). Here's the real alert surface, trimmed to its bones:
export const Alert = defineSurface(
'alert',
z.object({
variant: z.enum(['default', 'destructive', 'warn', 'info']).default('default'),
title: z.string().default('Are you sure?'),
description: z.string().default(''),
onAction: z.custom<() => void>().optional(),
onCancel: z.custom<() => void>().optional(),
}),
{ variant: 'alert', closeOnAction: true },
)Read the schema as the prop contract. A field with .default() is optional at the call site — leave it out and the default fills in. A field marked .optional() (like onAction) may simply be absent. Callbacks are typed with z.custom<() => void>() because Zod can't validate a function's shape, only that one is present.
The third argument is config, and two keys matter most:
variant and closeOnAction
variant: 'alert' makes the surface render as an AlertDialog on desktop (the focused, dismiss-on-purpose kind) instead of a regular Dialog. closeOnAction: true means the action button closes the surface for you after it runs. A surface can also carry a permission here — more on that below.
Opening a surface
You open a surface with openSurface, pulled from the useSurface store. Because the store is global, any component can open any surface — the caller and the surface never have to be near each other in the tree. This is how the roles page fires a destructive confirm:
const { openSurface } = useSurface()
openSurface(Surface.Alert, {
props: {
title: `Delete ${role.name} role?`,
description: 'This action cannot be undone.',
onAction: () => deleteRole(role.id),
variant: 'destructive',
},
})The props object is type-checked against the surface's schema. Notice what you didn't pass: cancelLabel, actionLabel — they have defaults, so they're optional. The onAction callback is where your real work goes; the surface just calls it when the user confirms.
Required props are enforced by the types
If a surface schema has a field with no .default() and no .optional(), then openSurface requires a props object containing it — TypeScript won't let you open it empty. A surface whose every field is defaulted (like CreateRole, whose schema is z.object({})) can be opened with no second argument at all.
Reading the merged props
Inside the component, you don't receive props as function arguments — you read them from the store with getProps(handle). What comes back is the merged result: the props from the openSurface call, parsed through the schema so defaults are applied.
export function AlertSurface() {
const { getProps } = useSurface()
const { variant, title, description, onAction, onCancel } = getProps(Alert)
// ... render header, body, footer
}The return type is z.output of the schema, so title is always a string (never undefined) even when the caller omitted it — the default guarantees it. That's the schema earning its keep: you read props without a single defensive ??.
Mounting the component
This is the step people forget, so say it out loud: the store only tracks open/closed state and props — it does not render anything. You must mount the surface component somewhere in the tree for it to appear. The roles page does exactly that, near the top of its JSX:
return (
<div className="hub-page">
<CreateRoleSurface />
{/* ... the rest of the page, buttons that openSurface(...) */}
</div>
)Mount it once, anywhere stable on the page. Opening and closing then flow entirely through the store — openSurface flips it on, the buttons inside flip it off. The shared Alert surface is mounted globally (in the authenticated layout) so any page can throw a confirm without re-mounting it.
The responsive container
You never write a Dialog or a Drawer yourself. Both the alert and the create-role surface wrap their body in SurfaceContainer, and that component makes the responsive call:
function SurfaceContainer({ children, surface }: SurfaceContainerProps) {
const isMobile = useIsMobile()
const Component = isMobile
? Drawer
: surface.variant === 'alert'
? AlertDialog
: Dialog
// ...
}So useIsMobile decides Drawer-vs-not, and variant decides AlertDialog-vs-Dialog on desktop. The compound parts you build with — SurfaceHeader, SurfaceTitle, SurfaceFooter, SurfaceActionButton — each switch their underlying primitive the same way, so your markup reads the same on every device.
The CreateRole surface shows the everyday pattern: a form inside a surface. It defines no props (z.object({})), builds an useAppForm, and closes itself on success.
export const CreateRole = defineSurface('create-role', z.object({}), {
closeOnAction: false,
})It sets closeOnAction: false deliberately — a form shouldn't vanish the instant you click submit. Instead it closes only after the mutation succeeds, calling closeSurface(CreateRole) from its own onSuccess.
Gating a surface
A surface can declare a permission in its config. When it does, SurfaceContainer checks the current user against that requirement (using the open props as context) and, if they fall short, renders a permission-denied view instead of your content — no extra code in your component.
A gate is defense in depth, not the only guard
The gate stops the surface from showing protected UI, but you should still hide the button that opens it. Pair surface permissions with the Can wrapper around the trigger — see Permissions for the whole story.