All docs
Everyone· 5 min

The architecture

Three layers — primitives, wrappers, app. The wrap-don't-use pattern and the lint guardrail.

The whole system is three layers stacked on each other. Once you see the layers, the rules make sense.

┌─────────────────────────────────────────────────┐
│  Layer 3 — The app                              │
│  (UpNext product code, screens, features)       │
│  Imports ONLY from @/components/upnext          │
└─────────────────────────────────────────────────┘
                       ▲
                       │ uses
                       │
┌─────────────────────────────────────────────────┐
│  Layer 2 — UpNext wrappers (SOURCE OF TRUTH)    │
│  components/upnext/*.tsx                        │
│  Locks variants, defaults, brand styling        │
└─────────────────────────────────────────────────┘
                       ▲
                       │ wraps
                       │
┌─────────────────────────────────────────────────┐
│  Layer 1 — shadcn primitives                    │
│  components/ui/*.tsx (installed via shadcn CLI) │
│  The base — accessibility, behavior, structure  │
└─────────────────────────────────────────────────┘

Layer 1 — shadcn primitives

These are the base components. shadcn ships them; we don't write them ourselves. They handle the hard, invisible stuff:

  • Keyboard navigation (arrow keys move between menu items, tab traps inside modals, escape closes overlays)
  • Screen reader semantics (correct ARIA roles, labels, live regions)
  • Focus management (focus moves into a dialog when it opens, returns to the trigger when it closes)
  • State machinery (controlled / uncontrolled props, animations on data attributes)

The primitives are intentionally unopinionated about brand. They give us behavior, we give them looks. They live at components/ui/ and are installed by npx shadcn@latest add.

Designer note: Think of these as Figma library components from a third party — they're well-built, you don't need to redo their work, but they're also not "UpNext." That's our job.

Layer 2 — UpNext wrappers (the source of truth)

This is the only layer we hand-design and the only one the app talks to. Every shadcn primitive gets wrapped in an UpNext component that:

  • Locks our variant set (e.g. our Button has primary | secondary | outline | ghost | destructive | success | warning | info | link — that's the list, full stop)
  • References only semantic tokens (bg-primary, never bg-blue-600)
  • Wires every state — hover, focus, active, disabled, loading — to the correct token
  • Adds UpNext-specific affordances when needed (e.g. loading prop on <Button> that swaps in a spinner)

These live at components/upnext/. There is exactly one file per wrapped primitive. There is exactly one barrel export at components/upnext/index.ts.

Designer note: Think of these as your Figma main components. Edit the main component → every instance updates. Same idea.

Engineer note: Wrappers consume shadcn primitives by direct import (@/components/ui/<name>). They are the only files allowed to do that.

Layer 3 — The app

Everything else. Pages, features, screens, business logic. The app imports from @/components/upnext and that's it.

// ✅ Correct
import { Button, Input, Card } from "@/components/upnext"

// ❌ Lint error
import { Button } from "@/components/ui/button"

The lint rule that makes this structural

eslint.config.mjs includes a no-restricted-imports rule that blocks @/components/ui/* imports everywhere except inside the wrapper layer and inside components/ui/ itself.

Try it: open a random app file and import { Button } from "@/components/ui/button". Your editor will mark it with an error:

Import from @/components/upnext, not @/components/ui. The UpNext wrapper layer is the single source of truth — see components/upnext/README.md.

This is what makes the rule structural rather than disciplinary. You don't need to remember to wrap — the linter won't let you skip it. The wrong choice is the difficult one.

The rule travels with the code when this system migrates into the real UpNext app. The same guardrail will protect production.

The Figma analogy (for designers)

Figma conceptDS-in-code equivalent
LibraryThe whole components/upnext/ folder
Main componentA wrapper file, e.g. upnext/button.tsx
InstanceA usage of <Button> in app code
Variants on a componentvariant prop on the wrapper
Local styleA semantic token like --primary
Detached instanceWhat the lint rule prevents — someone going around the wrapper to do their own thing
OverridePassing custom className (allowed but rare)

The wrap-don't-use pattern is the code equivalent of "always use the library component, never detach." The lint rule is your team's "you can't detach an instance" policy, enforced automatically.

What changes propagate

When you change…The change propagates to…
A token in globals.cssEvery component everywhere, light and dark mode
A wrapper in upnext/*.tsxEvery usage of that component in the app
A shadcn primitiveThe wrapper, then onward to every usage
A variant added to a wrapperAvailable to every consumer once the next deploy ships

That's the whole point. One source, one source, one source.

What we're explicitly NOT building

  • Custom primitives from scratch. We don't write our own Radix replacements. shadcn + Radix already solved accessibility — we'd just be reinventing the wheel poorly.
  • A theming engine. Light and dark mode are the only "themes." No per-brand variants, no white-labeling. The token layer is where all visual identity lives, period.
  • A documentation site separate from the playground. This file you're reading IS the documentation site. The playground IS the spec. One source.