Admin Portal โ Page Patterns & Design System โ
Last Updated: 2026-04-24 Scope: apps/admin/ โ Lantern admin portal
This is the universal pattern reference for building and modifying pages in the admin portal. Use it as a starting point for any new page, or as a checklist when reviewing existing pages for consistency.
Table of Contents โ
- Layout Skeleton
- Components to Reuse
- Navigation Hierarchy
- Toolbar Pattern
- Color Identity โ
--role-accent - Typography Rules
- Button Vocabulary
- Iconography
- Sortable Tables
- Responsive Grids
- Growth Chart Recipe
- Non-Negotiable Decisions
- Common Pitfalls
- Checklist for a New Page
- Form Controls (Dropdowns)
1. Layout skeleton (every page) โ
Every admin page follows this structure:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ [PageHeader] โ โ 68px, var(--surface)
โ Title ยท Subtitle [action buttons] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ [PageTabs] Tab1 ยท Tab2 ยท Tab3 โ โ underline tabs
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ [Page body โ scrollable] โ โ padding: space-4
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโJSX skeleton (adapt per page):
<div className="user-management-container">
<div className="user-management-main">
<PageHeader title={pageTitle} subtitle={pageSubtitle} actions={headerActions} />
{showTabs && (
<PageTabs tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
)}
<div className="user-management-body">
{renderTabContent()}
</div>
</div>
</div>Note on naming:
.user-management-container/main/bodyare page-specific classes today. If we want these truly generic, a future pass could rename them to.page-shell/__main/__body. The structure and CSS are the same either way.
2. Components to reuse โ
| Component | Path | Purpose |
|---|---|---|
<PageHeader /> | apps/admin/src/components/PageHeader.jsx | 68px topbar with title, subtitle (inline right of title), optional actions slot |
<PageTabs /> | apps/admin/src/components/PageTabs.jsx | Top-level page tabs, underline style |
<GrowthChart /> | apps/admin/src/components/GrowthChart.jsx | SVG area chart โ pass data, metric, metricPlural |
PageHeader props โ
titleโ string (rendered uppercase with letter-spacing automatically)subtitleโ string, optional (inline right of title, baseline-aligned)actionsโ JSX (put buttons here; Refresh + primary CTAs)
PageTabs props โ
tabsโ array of{ id, label, icon, disabled? }activeTabโ current tab IDonTabChangeโ callback(id) => void
3. Navigation hierarchy โ three levels โ
The admin portal uses a clear three-level navigation system. Each level has its own visual style to communicate hierarchy.
| Level | Style | Component | Purpose |
|---|---|---|---|
| 1. Page tabs | Underline, amber active | .page-tabs / <PageTabs /> | Distinct sections (Dashboard / Users / Activity) |
| 2. Sub-tabs | Segmented control in rounded container | .sub-tabs + .sub-tab | Filter/view within a section (All / Admins / Merchants / App Users) |
| 3. Future | Dropdowns, chips, detail drawer | Not yet built | Secondary filters, multi-select |
Sub-tabs usage pattern โ
<div className="sub-tabs" role="tablist">
{FILTERS.map((f) => (
<button
key={f.id}
role="tab"
aria-selected={active === f.id}
className={`sub-tab ${active === f.id ? 'active' : ''}`}
onClick={() => setActive(f.id)}
>
{f.icon}
<span>{f.label}</span>
{count != null && <span className="sub-tab__count">{count}</span>}
</button>
))}
</div>Count badges are high-value: they signal data distribution before the user clicks. Always include them when the numbers are available (pull from dashboardStats.stats or equivalent).
4. Toolbar pattern (sub-tabs + search) โ
Combine the sub-tabs with a search input in a flex row:
<div className="user-list-toolbar">
<div className="sub-tabs">...</div>
<div className="search-bar">...</div>
</div>.user-list-toolbar uses flex + justify-content: space-between. Sub-tabs sit left (primary control), search sits right.
- If a page has only sub-tabs (no search), drop the
.user-list-toolbarwrapper โ a bare.sub-tabsrenders fine. - If a page has only search, use the existing
.search-baras before.
Evolution path for more filters โ
Sub-tabs (segmented control) scale well to 4โ5 options. When you need more filtering power:
- Add a second row below for dropdowns (status, date range). Keep the primary sub-tabs prominent.
- When you hit 5+ concurrent filters or need multi-select, migrate to Notion/Airtable-style chip filters.
5. Color identity โ the --role-accent pattern โ
For any card or card-group where categories have semantic color:
.role-card--total { --role-accent: var(--accent-600); } /* amber โ brand */
.role-card--users { --role-accent: var(--info); } /* cyan */
.role-card--merchants { --role-accent: var(--success); } /* green */
.role-card--admins { --role-accent: var(--danger); } /* red */The base .role-card styles reference var(--role-accent) everywhere the color appears (top stripe via ::before, icon color, focus ring). Adding a new variant is a one-line change: create the modifier, set the token.
Reuse on other pages โ
Any grid of categorical items can use this pattern:
- Billing line items by provider
- Merchants by status
- Storage buckets by environment
- Any page where "these items belong to named categories"
The key constraint: the color must carry identity (user learns "green = merchants"), not decoration (random colors with no meaning).
6. Typography rules โ
| Use | Style |
|---|---|
| Page title (PageHeader) | 1rem, weight 600, UPPERCASE, letter-spacing: 0.03em |
| Section label (card header, chart title) | 0.75rem, weight 600, UPPERCASE, letter-spacing: 0.05em, color: var(--muted) |
| Hero number | 2.5โ3.5rem, weight 300 (light), font-variant-numeric: tabular-nums |
| Metric values | 0.8125rem, weight 500, tabular-nums |
| Body text | 0.875rem, weight 500 |
| Muted text (subtitles, helper) | 0.8125rem, color: var(--muted) |
Always use font-variant-numeric: tabular-nums for stacked numbers. This prevents the 1 vs. 8 width wobble that makes columns of numbers look uneven.
Why hero numbers are weight 300, not bold: Bold big numbers read as "alert" or "emergency." Light-weight large numbers read as "editorial" or "premium." The same number feels completely different at weight 300 vs. 700.
7. Button vocabulary โ
Use the existing button classes in apps/admin/src/styles.css:
| Class | Use |
|---|---|
.btn-primary | One or two primary CTAs per page (solid amber --accent-600) |
.btn-secondary | Utilities โ Refresh, Cancel, etc. |
.btn-danger | Destructive actions |
.btn-ghost | Tertiary, inline |
.btn-sm | Add for toolbar/header-level buttons |
.btn-icon | Icon-only (for collapsed states) |
CTA discipline โ
- Never have 3+
.btn-primaryon the same screen. The whole point of primary is hierarchy. - Refresh buttons stay
.btn-secondaryโ they're utilities, not actions. - Create-type CTAs can be
.btn-primaryโ they represent the page's most important actions.
8. Iconography โ
Use Lucide icons only. No emojis in UI chrome (titles, tabs, headers, buttons).
Standard sizes โ
| Context | Size |
|---|---|
| Top-level nav (sidebar) | 18px |
| Sub-tabs, card headers | 16px |
| Inline button icons, sort indicators | 14px |
| Tiny meta bits | 12px |
Coloring โ
Lucide icons inherit currentColor by default. Color them by coloring their parent:
.role-card__header {
color: var(--role-accent); /* icon inherits this */
}When emojis are OK โ
Emojis are fine in user-generated content:
- Lantern name badges (๐ฎ in
.lantern-name-badge) - User avatars (user-chosen initials/characters)
- Content the user typed
Not OK in UI chrome (navigation, headers, section labels, buttons).
9. Sortable tables โ
Pattern established on the Users table:
<th className="th-sortable" onClick={() => toggleSort('name')}>
<span className="th-content">
User
<SortIndicator field="name" />
</span>
</th>Key points โ
- The
<th>itself handles the click - Content wrapped in
<span className="th-content">(inline-flex) to keep label + icon on one line SortIndicatorrendersArrowUp/ArrowDownwhen active,ArrowUpDownat 30% opacity when idletoggleSortdefaults to desc for date fields, asc for text fields โ matches user intuition- Client-side sort via
useMemo; move to server-side if data grows large
SortIndicator component shape โ
const SortIndicator = ({ field }) =>
sortField === field ? (
sortDir === 'asc' ? <ArrowUp size={12} /> : <ArrowDown size={12} />
) : (
<ArrowUpDown size={12} className="sort-indicator-idle" />
)10. Responsive grids โ
Use explicit breakpoints, not auto-fit:
.some-grid {
display: grid;
grid-template-columns: repeat(4, 1fr); /* desktop */
gap: var(--space-3);
}
@media (max-width: 1200px) { .some-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .some-grid { grid-template-columns: 1fr; } }Two-column layouts with SVG content โ
If one column contains an SVG (like GrowthChart), use minmax(0, Xfr) on that column to prevent min-content blowout:
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.6fr);The minmax(0, ...) tells the grid "this column can shrink to zero if needed." Without it, a full-width SVG can push the column past its fair share.
11. Growth chart โ recipe โ
Full growth chart section (used on Dashboard and Activity pages):
<div className="dashboard-chart-section">
<div className="dashboard-chart-section__header">
<div>
<h3 className="dashboard-chart-section__title">Chart Title</h3>
<p className="dashboard-chart-section__subtitle">Helper text</p>
</div>
<div className="range-toggle" role="tablist">
{[7, 30, 90].map((d) => (
<button
key={d}
className={`range-toggle__btn ${range === d ? 'active' : ''}`}
onClick={() => setRange(d)}
>
{d}d
</button>
))}
</div>
</div>
<GrowthChart data={series} metric="signup" />
</div>Data shape for GrowthChart:
type DailyBucket = {
date: string // 'YYYY-MM-DD'
count: number
// optional per-series breakdown
users?: number
merchants?: number
admins?: number
}Default range: 90 days. Server returns 90 days of buckets; frontend slices to 7/30/90 based on the toggle. Changing range is instant (no refetch).
12. Non-negotiable decisions โ
These are settled choices โ don't re-litigate them without a strong reason.
--surface(zinc-900) for sidebar background and.page-header-barโ matching surface color makes them read as one continuous shelf--bg(pure black) for the page body- Border opacity
0.05for subtle dividers (matches the established docs/api-ref pattern) - 68px fixed height for all top-row bars (sidebar header, PageHeader, doc editor topbar, API ref topbar)
role="button"+tabIndex={0}on clickable divs that need to contain other buttons โ never nest<button>inside<button>- Amber (
--accent-600) means wayfinding (active tab, active sub-tab, active sidebar item) OR primary action (solid CTA) โ don't dilute it with decorative uses - All dropdowns use
react-selectvia the project wrappers โ never native<select>. See Form controls.
13. Common pitfalls โ
Things we tripped on building this system; don't repeat them:
- Child backgrounds cover parent backgrounds. If
.sidebar-header/nav/footerall setbackground: var(--surface), the parent.sidebarbackground color never shows through. Only set background at one level. min-heightvsheightfor headers. Use explicitheight: 68px(notmin-height) for bars whose bottom border must pixel-align across components.- Sort icons wrapping to a new line. Always wrap
<th>contents in<span class="th-content">withinline-flex+white-space: nowrap. - SVG chart stretching weirdly. Use
preserveAspectRatio="none"on the SVG +vectorEffect="non-scaling-stroke"on lines so strokes stay crisp. - Filter state baked into tab IDs. Keep filter state separate from tab state so you can compose them independently (e.g., set filter before activating tab).
- Nested
<button>elements. Invalid HTML + breaks screen reader announcement. Userole="button"on the outer<div>and real<button>only on inner interactive areas.
14. Copy-paste checklist for a new page โ
- [ ] Wrap in
.user-management-container+.user-management-main+.user-management-body - [ ]
<PageHeader />with title, subtitle, actions - [ ]
<PageTabs />if the page has multiple sections - [ ] Use Lucide icons (no emojis in UI chrome)
- [ ] Apply existing button classes; no one-off button styles
- [ ] Follow typography rules (uppercase labels, light hero numbers,
tabular-nums) - [ ] For categorical cards, use the
--role-accenttoken pattern - [ ] For filters within a page, use
.sub-tabs+.sub-tabpattern - [ ] For sortable tables, use
SortIndicator+th-contentwrapper - [ ] At most one or two
.btn-primaryper screen - [ ] Responsive grid uses explicit breakpoints (4 โ 2 โ 1 col)
- [ ] All dropdowns use
<StyledSelect>(admin) or<Select>(web) โ never native<select>
15. Form controls (dropdowns) โ
All dropdowns must use react-select via the project wrappers. Both native <select> and direct react-select imports are banned by ESLint (no-restricted-syntax + no-restricted-imports) so future PRs can't bypass the wrappers โ this also applies to the web app.
| App | Wrapper | Import |
|---|---|---|
apps/admin/ | StyledSelect | import StyledSelect from './StyledSelect' |
apps/web/ | Select | import Select from '@/components/Select' |
Why react-select (not native) โ
- Consistent dark/glass styling without per-callsite style objects
- Search-as-you-type (essential when option lists grow โ e.g. year picker, merchant picker)
- Keyboard a11y, portal'd menus that escape
overflow: hiddencontainers - Multi-select, async, and creatable variants available without changing the API
Standard call shape โ
Both wrappers take the same react-select props. Convert primitive form state with the find pattern:
const opts = [
{ value: 'cafe', label: 'Cafe' },
{ value: 'bar', label: 'Bar' },
]
<StyledSelect
options={opts}
value={opts.find((o) => o.value === form.businessType) || null}
onChange={(opt) => setForm((f) => ({ ...f, businessType: opt?.value ?? '' }))}
placeholder="Select business type"
isSearchable={false} /* set false when there are <8 options */
/>Override styles per slot via the styles prop โ wrapper defaults are deep-merged so you only specify what changes.
Exemptions โ
packages/api-docs-renderer/โ published shared package withoutreact-selectdep. Marked with// eslint-disable-next-line no-restricted-syntax.- New exemptions need a justifying comment.
Related โ
- DIRECTORY_DEFINITIONS.md โ docs organization
apps/admin/src/components/PageHeader.jsxapps/admin/src/components/PageTabs.jsxapps/admin/src/components/GrowthChart.jsxapps/admin/src/styles.cssโ all the CSS classes referenced here