Naalya Handbook
Panels & Surfaces

Side Panels

Define a slide-out panel with definePanel, open it by typed handle, read its params, and gate it by permission.

A side panel is the slide-out pane that appears beside the main content — the staff profile that opens when you click a row, the role editor that slides in to manage permissions. Rather than scatter that machinery across the app, the Education Hub gives every panel one home: a single *.panel.tsx file that declares what it needs, renders itself, and registers under a typed handle.

The mental model is small. You write a component, you describe its inputs and its permission, and definePanel hands you back a handle. Everywhere else in the app you open that panel by passing the handle — never a string, never raw props on the window. The plumbing in _registry/ does the rest: it tracks which panel is open, validates the data you passed, and renders a denied view if the user isn't allowed in.

One file, one panel

Each panel lives in its own kebab-case.panel.tsx under src/components/side-panels/. The folder's _registry/ is infrastructure — you almost never touch it. You write your file, the codegen picks it up, and the rest is automatic.

Defining a panel

The whole contract lives in the definePanel call at the bottom of the file. The real staff-profile.panel.tsx ends like this:

src/components/side-panels/staff-profile.panel.tsx
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 }),
  },
})

Read it as four answers. What renders? component. What data does it need from the caller? contextParams, a Zod schema. What in the URL does it depend on? routeParams. Who may see it? permission. The exported StaffProfile is a typed handle — that's the thing you pass around, and TypeScript reads the schema off it.

Two kinds of params

A panel can take input two ways, and they're easy to tell apart:

  • contextParams are arguments you pass explicitly at open timestaffId, a roleId. They're a Zod schema, so they're typed for IntelliSense and validated at runtime.
  • routeParams are read automatically from the URL — list the names, like ['campusId'], and the registry pulls them off the current route. You never pass these by hand.

The simplest panels need only context params. The ManageRole panel is exactly that — a roleId and nothing else:

src/components/side-panels/manage-role.panel.tsx
export const ManageRole = definePanel('manage-role', {
  component: ManageRolePanel,
  contextParams: z.object({ roleId: z.string() }),
})

Opening a panel

You open a panel with the useSidePanel hook and the Panel handle — never a bare string. Reach for both off the barrel, then call openPanel with the handle, a size, and the context params:

open from a staff list row
const { openPanel } = useSidePanel()

openPanel(Panel.StaffProfile, { size: 'wide' }, { staffId })

The call is fully type-safe. Because StaffProfile carries its schema, TypeScript demands the third argument match { staffId: string } — pass the wrong shape and it won't compile. If the panel declared routeParams instead, you'd skip that third argument entirely; the registry reads them from the URL for you.

Pass the handle, not the id

Always open with Panel.StaffProfile, the object — not the string 'staff-profile'. The handle is what gives you autocomplete on the params and a compile error when you forget one. The raw id is an internal detail of the registry.

Reading params inside

Inside the panel component you read the context params back with usePanelParams, handing it the same handle. That's what makes the result typed — the hook parses the live store value through the panel's schema and returns it:

src/components/side-panels/staff-profile.panel.tsx
function StaffProfilePanel() {
  const { staffId } = usePanelParams(StaffProfile)

  const { staffProfile, loadingStaffProfile } = useStaffProfile(staffId)
  // ... render the profile
}

Because the schema lives on the handle, staffId comes back as a string with no casting and no any. The component never reaches into the Zustand store directly — usePanelParams is the one doorway, and it guarantees the shape.

Gating by permission

The permission block is where a panel protects itself. The container resolves the open panel's config, runs the check through useMeetsRequirement, and — when it fails — renders a PermissionDenied view in place of your component. Your panel code never has to ask "is this allowed?"; declaring the requirement is enough.

For instance-level checks, the subject resolver turns the panel's params into the object CASL evaluates against — here, the specific staff record being opened:

the permission block (staff-profile.panel.tsx)
permission: {
  action: Action.READ,
  resource: Resource.STAFF,
  subject: ({ data }) => ({ id: data.staffId as string }),
}

No permission means open

The permission key is optional. A panel like ManageRole that omits it is always allowed to render — gating is opt-in. When you do add it, the denied view comes for free; you don't render it yourself. See Permissions for how action, resource, and subject resolve.

Sizes and persisted state

The size you pass at open time picks how the screen splits between the main content and the side pane. They run smallest to widest — smallest gives the panel the least room, widest the most:

SizeMainSide
smallest80%20%
small75%25%
normal70%30%
wide65%35%
widest60%40%

Which panel is open, its size, and the params you passed all live in a persisted Zustand store under the key sidepanel-state. Because it's persisted to localStorage, an open panel survives a page reload — a useful default, and one more reason the store is the single source of truth rather than component state. The store is part of the broader client state story.

Step 1: Create the empty file

Add a new file named for your feature. Leave it blank — the codegen plugin watches the folder and notices it:

terminal
touch src/components/side-panels/student-attendance.panel.tsx

Step 2: Let codegen scaffold it

On save, the Vite plugin scaffolds the empty file with a working definePanel skeleton and regenerates the barrel. You don't edit panel-definitions.gen.ts — it's auto-generated, and your new handle appears on Panel once the file exports one.

Step 3: Write the component

Fill in the component using the SidePanelNavBar and SidePanelBody containers, reading params at the top via usePanelParams. Keep it focused — a panel is a detail view, not a page.

Step 4: Register with definePanel

Export the handle at the bottom, declaring its params and (if needed) its permission. This is the line that puts it on the Panel map:

src/components/side-panels/student-attendance.panel.tsx
export const StudentAttendance = definePanel('student-attendance', {
  component: StudentAttendancePanel,
  contextParams: z.object({ studentId: z.string() }),
})

Step 5: Open it by handle

Anywhere in the app, pull openPanel off useSidePanel and open your new panel with Panel.StudentAttendance. The third argument is type-checked against the schema you just wrote:

open the new panel
openPanel(Panel.StudentAttendance, { size: 'normal' }, { studentId })

That's the full loop — file, codegen, component, register, open. Every panel in the app follows it.

Where to go next

On this page