Add a Surface
Create a responsive Dialog-or-Drawer with a Zod schema for props, mount it, and open it from anywhere.
A surface is the app's word for a centred modal — a confirmation dialog, a quick create form, a profile sheet. You define it once, mount it once, and from then on you can pop it open from anywhere with a single function call. No useState, no prop-drilling an isOpen boolean down three components, no wiring an onClose. The registry holds all of that for you.
The trick that makes surfaces pleasant is props are a Zod schema. You describe what a surface needs — a title, an onConfirm callback — as a z.object, and that one schema becomes the source of truth for both the runtime defaults and the TypeScript types at every call site. And because the same component renders a Dialog on desktop and a Drawer on mobile automatically, you never think about the responsive part.
This page is the build-a-surface checklist. The full mental model lives on Surfaces — read that if any step feels like magic.
Surface or side panel?
Surfaces are centred and modal — confirm, create, alert. A side panel slides in from the edge and is better for browsing or editing a record in context. If you want the edge, you want Add a Side Panel instead.
Step 1: Scaffold the file
Surfaces live in src/components/surfaces/, one per file, named <name>.surface.tsx. Just create an empty one:
touch src/components/surfaces/my-dialog.surface.tsxThat's all it takes to register it. The Vite codegen plugin (.vite-plugins/surfaces-codegen.ts) watches the folder and, on save, auto-scaffolds the boilerplate and regenerates _registry/surface-definitions.gen.ts — the barrel that adds MyDialog to the generated Surface map. If Surface.MyDialog comes back undefined, the plugin hasn't run yet; restart the dev server rather than editing the barrel.
Step 2: Define the surface
Call defineSurface with three things — a kebab-case id, the Zod schema that describes its props, and an optional config. The schema is the whole point. Fields with .default() become optional at the call site; everything else is required and type-checked. Callbacks are just z.custom.
import { z } from 'zod'
import { defineSurface } from './_registry'
export const MyDialog = defineSurface(
'my-dialog',
z.object({
title: z.string().default('Are you sure?'),
onConfirm: z.custom<() => void>().optional(),
}),
{ closeOnAction: true },
)defineSurface returns a typed handle — the MyDialog value you just exported. You'll pass it around in the next steps; you never reach into the registry directly. Two config flags are worth knowing now:
closeOnAction— whether the surface auto-closes after its action button fires. Set itfalsewhen you submit a form and want to close only on success.variant: 'alert'— renders an alert-style dialog (no dismiss-on-overlay-click) for destructive confirmations. The sharedAlertsurface uses this.
Step 3: Write the component
Below the definition, write the React component. Read your merged props — schema defaults already applied — with getProps, passing the handle so the types flow through. Then lay it out with the Surface* primitives, which give you the consistent header, footer, and close button for free.
import { useSurface } from './_registry'
import {
SurfaceActionButton, SurfaceCancelButton, SurfaceContainer,
SurfaceContent, SurfaceFooter, SurfaceHeader, SurfaceTitle,
} from './_registry'
export function MyDialogSurface() {
const { getProps } = useSurface()
const { title, onConfirm } = getProps(MyDialog)
// ... SurfaceContainer surface={MyDialog} wraps the content
}Always pass the handle, not the id
getProps(MyDialog), SurfaceContainer surface={MyDialog}, closeSurface(MyDialog) — every registry call takes the handle, never the string 'my-dialog'. The handle carries the schema, so this is what gives you typed, defaulted props instead of an unknown blob.
When the surface is really a form — like the real create-role surface — the body is just a TanStack Form inside SurfaceContent. Set closeOnAction: false, and call closeSurface(MyDialog) yourself in the form's onSuccess. See Forms for the form half of that story.
Step 4: Mount it
The registry tracks state (which surfaces are open, with what props); the component does the rendering. So your component has to be mounted somewhere persistent in the tree — it sits there invisibly until something opens it. The shared surfaces are mounted once in the authenticated root layout:
<AuthedSchoolTheme />
<AlertSurface />
<UserProfileSurface />
{/* add <MyDialogSurface /> here, then <Outlet /> */}
<Outlet />Mounting it once at the root means it's available on every authenticated page. If a surface is only ever used inside one feature, you can mount it lower down — but the root is the common home.
Step 5: Open it from anywhere
Now the payoff. Grab openSurface from useSurface, and call it with the Surface.MyDialog handle (from the generated registry) plus a props object. TypeScript will demand exactly the props your schema declared as required — defaulted ones are optional.
import { Surface } from '@/components/surfaces/_registry'
const { openSurface } = useSurface()
openSurface(Surface.Alert, {
props: {
variant: 'destructive',
title: `Remove ${streamName}?`,
onAction: async () => unassignCbtExamStream(/* ... */),
},
})That's the entire integration. The registry flips the surface open, your component reads the props you passed via getProps, and it renders as a Dialog on desktop or a Drawer on mobile — no responsive code on your side. Pass an onAction/onConfirm callback in props to react to the confirm button.
Props are overwritten each open, not merged
Each openSurface call replaces the surface's props wholesale — they don't accumulate across opens. So always pass the full props your component needs every time you open it, and let the schema's .default() values fill the rest.
Where to go next
Surfaces
The full mental model behind the registry, handles, and the responsive Dialog-or-Drawer.
Forms
Build the form that lives inside a create-style surface.
Add a Side Panel
The edge-anchored sibling pattern, for browsing and in-context editing.
Permissions
Gate a surface so a denied view renders when the user lacks access.