Styling
The Hub's minimal, ring-based visual language — quiet cards, what each OKLch token means, and the spacing and radius scale.
The Education Hub has a deliberately quiet look. Surfaces are flat, color shows up sparingly, and almost nothing has a hard edge. When you build a new screen, the goal isn't to decorate it — it's to make it disappear into the rest of the app. This page is the visual language that gets you there: how cards stay minimal, why everything is outlined with a ring instead of a border, and the token, spacing, and radius vocabulary underneath it all.
If Components & Patterns is the kit of blocks, this is the finish every block shares.
Rings, not borders
The single most defining choice in the whole UI: structure is drawn with rings, not borders. Look at the base Card — it has no border at all, just a hairline ring and a barely-there background tint:
<div
className="bg-card text-card-foreground rounded-xl py-4 ring-1 ring-foreground/10 group/card ..."
/>Why a ring and not a border? Three reasons that add up to a calmer interface. A ring is painted inside the box, so it never nudges layout the way a border's extra pixel does — and hover states can thicken or color it with zero reflow. It reads softer than a 1px line. And because it's just box-shadow underneath, it layers cleanly over the faint card tint instead of fighting it.
Reach for a ring before a border or a shadow
Borders and drop shadows both exist in the theme, but the house style leans on rings first. The shadow tokens (shadow-xs, shadow-sm) are intentionally tiny — elevation here comes from a ring and a hair of background tint, not a heavy float. A border-t shows up in exactly one common place: a CardFooter, to fence off an action bar.
Minimal cards
Cards are plain on purpose. The --card background is nearly white — oklch(0.993 0.0005 260), a whisper warmer than the page — so a card reads as a surface without a slab of contrast. The title is text-base font-medium, the description is text-muted-foreground, the corners are rounded-xl, and that's the whole costume. The content is what carries the card, not its chrome.
That restraint is what lets the ring system do the talking. A resting card is silent; an interactive one wakes up only on hover; a selected one holds a slightly louder ring. Nothing is shouting at rest.
The ring vocabulary
There's really one device — the ring — used at a few opacities, and the opacity is the signal. Heavier means "more important / more active":
| Ring | Means |
|---|---|
ring-1 ring-foreground/10 | the structural hairline on a resting card or container |
hover:ring-1 hover:ring-primary/40 | an interactive tile waking up under the cursor (lists, hub tiles) |
ring-1 ring-primary/60 | a selected card, or a school entity that wants more weight |
outline-ring/50 | the keyboard-focus ring every element gets for free |
A clickable tile is just the resting card plus a hover ring — one className, no border swap:
<Card className="bg-muted hover:ring-1 hover:ring-primary/40 transition-all cursor-pointer">The last row matters for accessibility: the base layer applies outline-ring/50 to every element, so focus is consistent and on-brand without you wiring it per component.
OKLch tokens
You never reach for a raw hex or a Tailwind palette color (blue-500, gray-200) — you reach for intent. Every color is a semantic token defined as an OKLch CSS variable in src/styles.css and exposed to Tailwind through @theme inline. These are the ones you'll actually type:
| Token | Use it for |
|---|---|
text-foreground | primary text — titles, values |
text-muted-foreground | secondary text — descriptions, metadata, hints |
bg-card | a raised surface (a card, a popover) |
bg-muted / bg-muted/50 | quiet fills — tiles, disabled rows, footers |
bg-primary/10 text-primary | an accent wash — the "current" pill, active nav |
ring-foreground/10 / ring-primary/40 | the rings above |
destructive · warning · info · success | the four fixed status colors |
The status four are fixed — they never re-tint to a school's brand, so a destructive action is always the same red. Everything else (the primary and the neutrals) is re-derived per school at runtime, which is why you must use the token and never a literal — see Per-School Theming. The same tokens flip wholesale in dark mode; because you only ever named the intent, your UI follows along with no extra work.
The canonical accent you'll reuse most is the "current" pill:
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Current
</span>Spacing & radius
Radius flows from one variable. --radius is 0.7rem in light mode (a noticeably rounded feel) and the rounded-* steps are derived from it, so the whole app curves consistently:
rounded-xl— the default for cards and containersrounded-lg— smaller chrome like an icon wellrounded-full— pills, avatars, the search affordance
Spacing leans on a small, repeated set rather than bespoke numbers: pages pad with p-4 md:p-6, cards use py-4 px-4, and grids breathe with gap-4 when tight or gap-6 when the cards are larger. Stick to these rungs and a new screen lines up with its neighbours automatically.
One global transition
You rarely write a transition class, because the stylesheet already gives interactive elements one:
button, input, textarea, select, [role='button'] {
@apply transition-all duration-200;
}So when you add hover:ring-primary/40 or a color shift, it eases in on its own — re-declaring transition-all is just noise. The same base layer hands every element the outline-ring/50 focus ring, so smooth, on-brand interactions are the default, not something you assemble.
The recipe for "feels like the Hub"
Put it together and a screen that belongs here is doing five quiet things:
- A flat
bg-backgroundpage — no slab of color behind the content. - Content sitting in
bg-cardcards outlined with aring, never a border. text-muted-foregroundfor anything secondary, so the hierarchy reads at a glance.primaryused only as an accent — a ring, a pill, the active nav item — never as a fill behind big areas.rounded-xlcorners and the shared spacing rungs, riding the one global transition.
When in doubt, do less
If a card needs a border and a shadow and a background color to feel finished, it's usually carrying too little content or too much chrome. Strip back to a ring and a tint first — the minimal version is almost always the on-brand one.