Skip to content

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 โ€‹

  1. Layout Skeleton
  2. Components to Reuse
  3. Navigation Hierarchy
  4. Toolbar Pattern
  5. Color Identity โ€” --role-accent
  6. Typography Rules
  7. Button Vocabulary
  8. Iconography
  9. Sortable Tables
  10. Responsive Grids
  11. Growth Chart Recipe
  12. Non-Negotiable Decisions
  13. Common Pitfalls
  14. Checklist for a New Page
  15. 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):

jsx
<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/body are 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 โ€‹

ComponentPathPurpose
<PageHeader />apps/admin/src/components/PageHeader.jsx68px topbar with title, subtitle (inline right of title), optional actions slot
<PageTabs />apps/admin/src/components/PageTabs.jsxTop-level page tabs, underline style
<GrowthChart />apps/admin/src/components/GrowthChart.jsxSVG 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 ID
  • onTabChange โ€” 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.

LevelStyleComponentPurpose
1. Page tabsUnderline, amber active.page-tabs / <PageTabs />Distinct sections (Dashboard / Users / Activity)
2. Sub-tabsSegmented control in rounded container.sub-tabs + .sub-tabFilter/view within a section (All / Admins / Merchants / App Users)
3. FutureDropdowns, chips, detail drawerNot yet builtSecondary filters, multi-select

Sub-tabs usage pattern โ€‹

jsx
<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).


Combine the sub-tabs with a search input in a flex row:

jsx
<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-toolbar wrapper โ€” a bare .sub-tabs renders fine.
  • If a page has only search, use the existing .search-bar as 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:

css
.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 โ€‹

UseStyle
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 number2.5โ€“3.5rem, weight 300 (light), font-variant-numeric: tabular-nums
Metric values0.8125rem, weight 500, tabular-nums
Body text0.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:

ClassUse
.btn-primaryOne or two primary CTAs per page (solid amber --accent-600)
.btn-secondaryUtilities โ€” Refresh, Cancel, etc.
.btn-dangerDestructive actions
.btn-ghostTertiary, inline
.btn-smAdd for toolbar/header-level buttons
.btn-iconIcon-only (for collapsed states)

CTA discipline โ€‹

  • Never have 3+ .btn-primary on 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 โ€‹

ContextSize
Top-level nav (sidebar)18px
Sub-tabs, card headers16px
Inline button icons, sort indicators14px
Tiny meta bits12px

Coloring โ€‹

Lucide icons inherit currentColor by default. Color them by coloring their parent:

css
.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:

jsx
<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
  • SortIndicator renders ArrowUp/ArrowDown when active, ArrowUpDown at 30% opacity when idle
  • toggleSort defaults 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 โ€‹

jsx
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:

css
.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:

css
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):

jsx
<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:

ts
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.05 for 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-select via the project wrappers โ€” never native <select>. See Form controls.

13. Common pitfalls โ€‹

Things we tripped on building this system; don't repeat them:

  1. Child backgrounds cover parent backgrounds. If .sidebar-header/nav/footer all set background: var(--surface), the parent .sidebar background color never shows through. Only set background at one level.
  2. min-height vs height for headers. Use explicit height: 68px (not min-height) for bars whose bottom border must pixel-align across components.
  3. Sort icons wrapping to a new line. Always wrap <th> contents in <span class="th-content"> with inline-flex + white-space: nowrap.
  4. SVG chart stretching weirdly. Use preserveAspectRatio="none" on the SVG + vectorEffect="non-scaling-stroke" on lines so strokes stay crisp.
  5. 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).
  6. Nested <button> elements. Invalid HTML + breaks screen reader announcement. Use role="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-accent token pattern
  • [ ] For filters within a page, use .sub-tabs + .sub-tab pattern
  • [ ] For sortable tables, use SortIndicator + th-content wrapper
  • [ ] At most one or two .btn-primary per 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.

AppWrapperImport
apps/admin/StyledSelectimport StyledSelect from './StyledSelect'
apps/web/Selectimport 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: hidden containers
  • 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:

jsx
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 without react-select dep. Marked with // eslint-disable-next-line no-restricted-syntax.
  • New exemptions need a justifying comment.

Built with VitePress