Components & Patterns
The everyday building blocks — URL-state tabs, ring cards, Hugeicons sizing, OKLch tokens, grids and empty states.
Most screens in the Education Hub aren't built from scratch — they're assembled from a small kit of recurring patterns. Once you can recognize them, a new page stops being a blank canvas and becomes a matter of reaching for the right block. This page is a tour of that kit: the tabs that live in the URL, the cards that prefer rings to borders, the icons sized by context, the tokens that carry every color, the grids that reflow on their own, and the empty states you should never hand-roll.
None of these are exotic. They're the muscle memory of the codebase — and the canonical source of truth is the frontend-conventions skill in the repo, which this page distills. The finish every block shares — the ring system, the token palette, spacing and radius — has its own page, Styling; here we focus on the blocks themselves.
Tabs in the URL
When a page has tabs, the active one belongs in the address bar, not in component state. That way a teammate can paste a link straight to the "Settings" tab and land there. We get this for free with nuqs, which mirrors a query param into a React state hook:
import { parseAsString, useQueryState } from 'nuqs'
const [currentTab, setCurrentTab] = useQueryState(
'currentTab',
parseAsString.withDefault('overview'),
)Wire that pair straight into the shadcn Tabs as value and onValueChange, and the URL becomes the single source of truth. The tab bodies don't clutter the route file — each lives in a co-located -partials/-feature.tab.tsx and exports a <TabsContent>:
export function GuardianOverviewTab() {
return <TabsContent value="overview" className="mt-4 space-y-8">{/* ... */}</TabsContent>
}Tabs are not router-driven
One route file owns every panel — switching tabs never changes the route, only a search param. The standard param is currentTab (a couple of pages use tab or streamTab). To deep-link, pass router search like currentTab: 'settings'. Reserve local useState tabs for side panels and surfaces, never full pages.
Cards
Cards in this app whisper rather than shout. Instead of a hard border, the base Card carries a faint ring-1 ring-foreground/10 — and interactive tiles stay borderless until you hover, when a colored ring fades in. For a clickable grid tile in a list, that's a one-line className:
<Card className="bg-muted hover:ring-1 hover:ring-primary/40 transition-all cursor-pointer">The opacity encodes importance — /40 for everyday list tiles, /60 for selected cards and school entities. The full ring vocabulary, and why cards stay this minimal, is on Styling.
The base Card also registers a group/card class, which is the hook for hover-revealed chrome like an overflow menu. The menu button sits invisible at rest and appears with the group:
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 group-hover/card:opacity-100 data-popup-open:opacity-100"
>
<HugeiconsIcon icon={MoreHorizontalIcon} />
</Button>The data-popup-open keeps the button visible while its menu is open — otherwise it would vanish the moment your cursor left the card.
Icons sized by context
Icons come from Hugeicons, rendered through a single HugeiconsIcon wrapper. There's no one global size — the size is contextual, a quiet signal of what kind of element you're looking at. The common rungs: 20 for sidebar nav, 18 for actions and dropdown items, 16 for chevrons and trailing arrows, 15 for metadata rows, and 30 for the big icon in an empty state. The default strokeWidth={1.5} is the dominant app-UI weight.
A recurring flourish is the icon well — a rounded bg-muted square that turns primary when you hover its parent tile:
<span className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors group-hover:bg-primary/10 group-hover:text-primary">
<HugeiconsIcon icon={action.icon} size={20} strokeWidth={1.5} />
</span>Note it keys off the plain group (the surrounding tile), not group/card — the well and the card-menu listen to two different group scopes, so they animate independently.
Tokens, not colors
The full OKLch token palette — and the rule that you reach for intent (text-muted-foreground, bg-card) rather than a raw hex — lives on Styling. The one accent you'll reach for most is the "current" pill on an active term or selected scope:
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Current
</span>Transitions are already global
A base rule in src/styles.css applies transition-all duration-200 to every interactive element — buttons, inputs, anything with role="button". So when you add hover:ring-primary/40 it animates smoothly with no extra transition class. Don't re-declare it.
Grids that reflow
Card grids here are container-aware, not breakpoint-driven. The reason is the side panels: when a panel slides in and narrows the content area, a viewport breakpoint grid wouldn't notice — but an auto-fill grid reflows to fit the space it actually has. The .responsive-grid utility bakes that in, and you set the per-card floor with an arbitrary --grid-min:
<div className="responsive-grid w-full mt-5 gap-6 [--grid-min:260px]">
{/* cards */}
</div>Behind the class it's just grid-template-columns: repeat(auto-fill, minmax(var(--grid-min), 1fr)). The inline arbitrary form grid-cols-[repeat(auto-fill,minmax(240px,1fr))] is equivalent and also common. Rough floors: 180px for stat chips, 240px for subjects, 300px for academic years (pair with gap-6), 320px for curriculum. Prefer either of these over sm:grid-cols-2 lg:grid-cols-3 — the older breakpoint grids are debt, not a template.
Empty states
When a list has nothing in it, don't improvise a dashed box or an inline h-62 bg-muted .... Wrap the <Empty> tree in the .page__empty-container utility, which owns that backdrop in one place:
<div className="page__empty-container">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<HugeiconsIcon icon={SearchMinusIcon} size={30} />
</EmptyMedia>
<EmptyTitle>No campuses yet</EmptyTitle>
<EmptyDescription>Create your first campus to get started.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Campus</Button>
</EmptyContent>
</Empty>
</div>Always include EmptyMedia variant="icon" with a size-30 glyph, and when the list is empty, move the page's primary action into EmptyContent and hide the header button — the empty state becomes the call to action.
One container, many states
The .page__empty-container is literally h-62 bg-muted mt-8 flex items-center justify-center rounded-xl. Inlining those classes is the exact debt this utility exists to retire — reach for the class so every empty state stays identical and a single edit restyles them all.