Naalya Handbook
Design System

Design System

The UI conventions at a glance, and the one base-ui rule that surprises everyone coming from Radix.

The Education Hub runs on shadcn/ui — so the components in src/components/ui will feel instantly familiar. But there's a twist that catches almost everyone on day one: under the hood, these primitives are built on base-ui, not Radix. Most of the time you won't notice. The one place you will is composition — how you make a DropdownMenuTrigger render as a Button.

This page is the map. It names the pillars the design system rests on, points you at the four deeper sub-pages, and gets that base-ui surprise out of the way first so it never bites you twice.

render, not asChild

In a Radix codebase, when you want a trigger to become another component you reach for asChild — you nest the real element as a child and Radix merges its props onto it. base-ui drops that pattern entirely. Instead, every composable primitive accepts a render prop: you pass the element you want it to render as, and base-ui merges its behaviour onto that.

So the muscle memory you bring from Radix produces the wrong code here. Here's the contrast, side by side:

Radix habit — does NOT work here
<DropdownMenuTrigger asChild>
  <Button>Actions</Button>
</DropdownMenuTrigger>
base-ui — the correct form in this repo
<DropdownMenuTrigger render={<Button>Actions</Button>} />

You can see this wired into the primitives themselves. BreadcrumbLink is the canonical example — it calls base-ui's useRender and merges its own classes onto whatever you hand to render:

src/components/ui/breadcrumb.tsx
function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<'a'>) {
  return useRender({
    defaultTagName: 'a',
    props: mergeProps<'a'>(
      { className: cn('hover:text-foreground transition-colors', className) },
      props,
    ),
    render,
  })
}

That's why a breadcrumb that links to a route reads as <BreadcrumbLink render={<Link to={...} />}> — the Link does the navigating, BreadcrumbLink lends it the styling.

The one rule to internalise

Reach for render whenever you would have reached for asChild. If you find yourself nesting a Button inside a trigger and wondering why two boxes render, you've written the Radix form. Pass the element to render instead — and pass a single element, not children.

The pillars

Everything else is convention rather than surprise. These are the load-bearing patterns; each holds across every staff-hub and guest page, so learning them once pays off everywhere.

  • Page shells — pages are plain route components wrapped in a CSS utility, .hub-page for staff, .guest-page for guests. There is no generic page-container component.
  • Inline headers — there is no shared PageHeader. The title-and-action block is an inline flex items-end justify-between, repeated by hand.
  • Navbar breadcrumbs — breadcrumbs live in the navbar, not the page body, and are driven entirely off the URL pathname.
  • URL-state tabs — tabs keep their selected value in the URL via nuqs (currentTab), so a tab is deep-linkable and survives a refresh.
  • Rings over borders — cards lean on soft ring-1 outlines that brighten on hover, rather than hard borders.
  • Hugeicons — icons come from Hugeicons, sized contextually per surface (no single global size) with strokeWidth={1.5}.
  • OKLch semantic tokens — every colour is an OKLch CSS variable behind a semantic name like text-muted-foreground; never reach for a raw colour.
  • Container-aware grids — card grids reflow with the container (via auto-fill + minmax), so they respond when a side panel narrows the content area, not just at viewport breakpoints.
  • Consistent empty states — page-level empties always wrap an <Empty> tree in .page__empty-container, never an ad-hoc dashed box.
  • Per-feature data tables — there is no monolithic data-table component; each feature composes useDataTable and DataTableShell under its own folder.

The fuller reference

This page is the orientation. The exhaustive, always-current source of truth lives in the repo at .claude/skills/frontend-conventions/SKILL.md — it carries the exact class strings, the icon-size table, the spacing scale, and the filter-and-pagination recipes. When the prose here and that file ever drift, trust the skill file; it sits next to the code and is updated with it.

Known debt — don't propagate

A few patterns in the codebase are mistakes we're living with, not models to copy. Sorting UI does not exist anywhere even though the table machinery enables a sorted row model — don't assume a header is clickable to sort. Some older pages still use breakpoint grids like sm:grid-cols-2 lg:grid-cols-3 instead of container-aware ones. And the breadcrumb components read an activeTab query param to append a tab suffix, but pages store tab state under currentTab, so that suffix is effectively dead code. Build on the preferred pattern, not these.

Where to go next

Four sub-pages take each cluster of pillars in turn. Start wherever your task lives.

On this page