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:
<DropdownMenuTrigger asChild>
<Button>Actions</Button>
</DropdownMenuTrigger><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:
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-pagefor staff,.guest-pagefor guests. There is no generic page-container component. - Inline headers — there is no shared
PageHeader. The title-and-action block is an inlineflex 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-1outlines 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
useDataTableandDataTableShellunder 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.
Styling
The visual language — minimal ring-based cards, the OKLch token palette, spacing and radius.
Layout and Pages
Page shells, inline headers, navbar breadcrumbs, URL-state tabs, and empty states.
Components and Patterns
URL-state tabs, soft-ring cards, Hugeicons, responsive grids, and empty states.
Data Tables
The per-feature table stack — filtering, pagination, search, and columns.