Add a Side Panel
Create a slide-out panel end to end — scaffold the file, define it, then open it by typed handle.
A side panel is the slide-out you've seen everywhere in the Hub — click a staff member in a table and their profile glides in beside the page; click a role and a permissions editor appears. They're for detail views, inspectors, and forms that sit alongside the main content rather than taking over the screen. This recipe walks you through building one from an empty file to a typed openPanel call.
The big idea worth holding onto: you never wire a panel up by hand. You drop a file in the right folder, export it through definePanel, and a codegen plugin discovers it and makes Panel.YourPanel available everywhere — fully typed. No barrels to edit, no registry to import into. That's the whole loop you're about to do four times.
Read the concepts first
This is the hands-on version of Panels and Surfaces → Side Panels. That page explains the split-pane layout, sizing, and how contextParams differ from routeParams. Come here when you just want the steps.
Step 1: Create the file
Everything lives in src/components/side-panels/, and the naming is strict — a kebab-case filename ending in .panel.tsx. Create an empty one:
touch src/components/side-panels/staff-profile.panel.tsxThat's genuinely all you have to do to register it. The Vite codegen plugin (.vite-plugins/sidepanels-codegen.ts) watches the folder, and when it sees an empty *.panel.tsx file it auto-scaffolds the boilerplate for you on save — the component skeleton, the imports, and the definePanel export. It also regenerates _registry/panel-definitions.gen.ts, which is the barrel that makes your panel a member of the Panel object.
Don't touch the generated files
_registry/panel-definitions.gen.ts is auto-generated and the _registry/ folder is infrastructure — never edit either by hand. Your job is only ever the *.panel.tsx file. If Panel.YourPanel doesn't show up, the plugin hasn't run; restart the dev server rather than editing the barrel.
Step 2: Write the component
A panel is just a React component that fills the slide-out. Two container pieces give it the standard chrome — SidePanelNavBar for the top bar and SidePanelBody for the scrollable content. To read the data the caller handed you, call usePanelParams with your own panel handle:
function StaffProfilePanel() {
const { staffId } = usePanelParams(StaffProfile)
const { staffProfile, loadingStaffProfile } = useStaffProfile(staffId)
// ...
return (
<>
<SidePanelNavBar>
<span className="font-medium text-sm">Staff Profile</span>
</SidePanelNavBar>
<SidePanelBody>{/* profile content */}</SidePanelBody>
</>
)
}Notice usePanelParams(StaffProfile) is passed the panel handle itself — that's how it knows the shape of { staffId } and gives you full type inference. From there the component is ordinary Hub code: fetch with a query hook like useStaffProfile, render a loading state, and lay out the body. (The data layer covers those hooks.)
Step 3: Define and export it
The component is private; the named export is what the rest of the app uses. Wrap it in definePanel with a kebab-case id and a contextParams Zod schema describing the data a caller must pass:
export const StaffProfile = definePanel('staff-profile', {
component: StaffProfilePanel,
contextParams: z.object({ staffId: z.string() }),
permission: {
action: Action.READ,
resource: Resource.STAFF,
subject: ({ data }) => ({ id: data.staffId as string }),
},
})The permission block is optional — add it to gate the panel, and a denied view renders for users who fail the check (see Permissions). If your panel instead depends on values from the URL, declare them with the optional routeParams array; they're read automatically from the route and never passed by hand:
export const ManageRole = definePanel('manage-role', {
component: ManageRolePanel,
contextParams: z.object({ roleId: z.string() }),
// routeParams: ['campusId'], // optional — auto-read from the URL
})Stick to the naming table
The codegen plugin extracts the exported const from your definePanel call, so the conventions matter: file staff-profile.panel.tsx, id 'staff-profile', const StaffProfile, component StaffProfilePanel. Match all four and the panel just appears.
Step 4: Open it anywhere
Now any component can launch your panel. Grab openPanel from useSidePanel and call it with three things — the typed handle, the size, and the context params:
const { openPanel } = useSidePanel()
// later, in a click handler:
openPanel(Panel.StaffProfile, { size: 'wide' }, { staffId })This call is fully type-safe. Because StaffProfile declared contextParams: z.object({ staffId: z.string() }), TypeScript requires that third argument to be { staffId: string } — pass the wrong shape and it won't compile. Sizes run from smallest through widest (small, normal, wide in between), controlling how much of the screen the panel claims. A panel with no params drops the third argument entirely: openPanel(Panel.Notifications, { size: 'small' }).
And that's the full loop — empty file, component, definePanel, openPanel. The same four steps cover the staff profile inspector, the role editor, and every other panel in the Hub.
Where to go next
Side Panels
The concepts behind this recipe — layout, sizing, context vs route params.
Add a Surface
Build the smaller modal-style surfaces panels open for alerts and quick edits.
Permissions
How the optional permission block gates a panel behind an action and resource.
Add a Data Feature
Wire up the query hooks your panel calls to load its data.