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, neverbg-blue-600) - Wires every state — hover, focus, active, disabled, loading — to the correct token
- Adds UpNext-specific affordances when needed (e.g.
loadingprop 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 concept | DS-in-code equivalent |
|---|---|
| Library | The whole components/upnext/ folder |
| Main component | A wrapper file, e.g. upnext/button.tsx |
| Instance | A usage of <Button> in app code |
| Variants on a component | variant prop on the wrapper |
| Local style | A semantic token like --primary |
| Detached instance | What the lint rule prevents — someone going around the wrapper to do their own thing |
| Override | Passing 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.css | Every component everywhere, light and dark mode |
A wrapper in upnext/*.tsx | Every usage of that component in the app |
| A shadcn primitive | The wrapper, then onward to every usage |
| A variant added to a wrapper | Available 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.