UI / Design System
UI / Design System
What it does
A vendored component library — the code lives in this repo under
components/ui/, not a node_modules black box, matching this starter's
"you own it, here's how to remove it" convention — built on shadcn/ui
(Base UI-backed, not Radix) on top of a complete CSS-variable token
contract (colors, radius, sidebar, chart tokens) with class-based
light/dark theming via next-themes, dark by default. Covers the whole
site: marketing, auth, the app shell, and the admin area, all sharing the
same tokens, primitives, and interaction patterns (confirmation dialogs on
every destructive action, Select/Switch/Checkbox instead of native
form controls, Table/Badge/Card instead of hand-copied markup).
How to configure it
Rebrand recipe — change 4 variables in app/globals.css (both the
:root block for light and the .dark block for dark) and every button,
link, focus ring, and chart across the whole site follows:
| Variable | Role |
|---|---|
--primary | Fill color for primary buttons/badges, and — since nothing else overrides it — link/accent text color |
--primary-foreground | Text color on a --primary-filled surface (must stay AA-contrasted against --primary, not against the page background) |
--ring | Focus-visible ring color; conventionally matches --primary |
--chart-1 | First chart series color; conventionally matches --primary |
Everything else (--secondary, --muted, --border, …) is a neutral gray
scale, unrelated to brand color. app/opengraph-image.tsx and
app/(marketing)/blog/[slug]/opengraph-image.tsx need the same color
changed by hand too — ImageResponse renders through Satori, which has no
access to CSS custom properties, so those two files hold hex literals that
mirror the .dark values instead of referencing the variables.
Dark mode default — components/theme-provider.tsx's
defaultTheme="dark" prop in app/layout.tsx. enableSystem still lets a
visitor opt into following their OS; flip defaultTheme to "system" to
make that the default instead.
How it works
Foundation
components.json— shadcn CLI config.style: "base-nova"(this installed CLI version's default),baseColor: "neutral",cssVariables: true, aliases@/components,@/lib/utils,@/components/ui, icon librarylucide. Letsnpx shadcn add <component>vendor new components consistent with what's already here.lib/utils.ts'scn(...)— merges Tailwind classes viaclsx+tailwind-merge. Every vendored component and every hand-written one that composes classes conditionally uses this, not template-literal string concatenation (which caused specificity bugs the pre-migration code worked around with!importantin a couple of places).- Primitive engine:
@base-ui/react(MUI's headless component library), not@radix-ui/*— shadcn's current default engine for newshadcn addoutput. Components built on it use arender/prop- forwarding pattern instead of Radix'sasChild. See "Base UI vs Radix" below for the specific API differences this surfaced. shadcnnpm package (a devDependency — build-time CLI + a shared Tailwind CSS import, never imported from application runtime code) providesshadcn/tailwind.css, imported once fromapp/globals.css: shared@custom-variantrules for Base UI'sdata-*attributes (data-open,data-checked,data-disabled, …) and the accordion open/close keyframes, so every vendored component gets consistent open/closed/checked styling without redeclaring those variants per file.app/globals.css— the full shadcn token contract (background, foreground, card, popover, primary, secondary, muted, accent, destructive, border, input, ring, chart-1..5, sidebar-*, radius) plus one addition shadcn's own contract lacks: a--warning/--warning-foregroundpair (amber), used for banners/alerts that are "notable, not broken" (impersonation/trial banners,past_duebilling alerts — see observability.md, billing.md).@custom-variant dark (&:is(.dark *))switches every token from a.darkclass on<html>, notprefers-color-scheme. Every color is a plain hex value (not oklch, despite that being this shadcn version's own default output) to match this codebase's pre-existing convention and stay trivially greppable/diffable.- Dark mode /
next-themes—components/theme-provider.tsxwrapsapp/layout.tsx's children;attribute="class"stamps.dark/.lighton<html>.components/theme-toggle.tsxis a plain native<button>(notcomponents/ui/button.tsx— a themed toggle button styling itself with the theme it's about to switch away from is circular) mounted in the marketing header and both app/admin sidebars.- Known, accepted console diagnostic: next-themes injects a raw
<script>as a JSX child specifically so the browser executes it synchronously, before hydration — that's what sets the theme class pre-paint and avoids a flash of the wrong theme. React 19's stricter hydration validation logs "Encountered a script tag while rendering React component" for this pattern (a recoverable dev-mode "Console Error", or a minified React error #418 in production) and recovers by re-rendering that one subtree client-side. Inherent to next-themes' technique, not a bug here — the theme class is always set correctly and the page always renders correctly either way. See the comment incomponents/theme-provider.tsx; there's no fix that keeps zero-FOUC theming and avoids this.
- Known, accepted console diagnostic: next-themes injects a raw
- Shiki dual-theme code blocks —
components/mdx/mdx-content.tsxpassesrehype-pretty-codethemes: { dark: "github-dark", light: "github-light" }, which emits only--shiki-light/--shiki-dark(per token) and--shiki-light-bg/--shiki-dark-bg(on the<pre>) as inline custom properties — it never setscolor/background-coloritself.app/globals.cssconsumes them:pre[data-theme](and its dark counterpart) applybackground-color/colorfrom those variables.
Component inventory
Vendored via npx shadcn add <name> (works against this repo's forked
Next.js — verified repeatedly). Every component under components/ui/ is
either a straight vendor or has a documented, deliberate deviation:
button.tsx— variantsdefault/outline/secondary/ghost/destructive/link; sizesdefault/xs/sm/lg+icon/icon-xs/icon-sm/icon-lg.loading(spinner + auto-disable) and<SubmitButton>(readsuseFormStatus()foruseActionState/action={}forms) are this project's own additions on top of the vendored primitive — kept because ~22 files depend on that exact API, not part of shadcn's own component.SubmitButtonlives in its own file,submit-button.tsx, re-exported frombutton.tsx— see "RSC and 'use client' boundaries" below for why.input.tsx,textarea.tsx,label.tsx— straightforward vendor.Inputis Base UI-backed;Textareastays a plain native<textarea>(Base UI doesn't wrap it).select.tsx,checkbox.tsx,switch.tsx— see "Base UI vs Radix" below forSelect's one real gotcha (itemsprop required for correct label display).card.tsx,badge.tsx,separator.tsx,skeleton.tsx,table.tsx,tabs.tsx,avatar.tsx,tooltip.tsx—Cardreplaced two competing pre-migration "bordered box" conventions and several locally-declaredCard/Stathelper functions reimplementing the same thing;Tablereplaced 5 hand-copied<table>shells across the admin pages.TooltipProvideris mounted once inapp/layout.tsx.dialog.tsx,alert-dialog.tsx,dropdown-menu.tsx,sheet.tsx,accordion.tsx— used throughout to add confirmation to every previously-unconfirmed destructive action (session revoke, org member remove, API key/webhook delete, admin ban) and to replace two hand-rolled popovers with real ARIA menus.sonner.tsx— replaced a hand-rolled toast context provider (components/ui/toast.tsx, deleted). Wired tonext-themes(useTheme(), so toasts follow light/dark) and to this repo's tokens (--normal-bg: var(--popover), etc.) — no hand-tuned colors.<Toaster />inapp/layout.tsxreplaces the old<ToastProvider>. Callers useimport { toast } from "sonner"+toast.success(...)/toast.error(..., { description })/plaintoast(...).spinner.tsx— the one pre-existing hand-rolled primitive kept as-is: it already usedcurrentColorthroughout, so there was nothing token-specific to migrate.
Shells
components/navigation/sidebar.tsx— the single shared shell for bothapp/(app)/layout.tsxandapp/(admin)/layout.tsx. Nav items, brand mark, and header/footer extras are all props (SidebarNavItem[],brand,headerExtra,footerLinks,topbarExtra); each layout supplies its own item list. Active-route highlighting viausePathname()exact match (aria-current="page"+ a--sidebar-accentbackground). Desktop shows a fixed<aside>; belowmd, a topbar with a hamburgerSheetrenders the sameSidebarContent(brand, org switcher, nav, user menu) in a slide-over — not a stripped-down mobile menu. User menu is an avatar + emailDropdownMenuwith sign-out; a separate always-visibleThemeTogglesits next to it (one click to switch themes, not two).components/settings/settings-nav.tsx— a secondary, in-page tab strip (Profile/Organization/Security/API keys/Webhooks) mounted viaapp/(app)/settings/layout.tsx, wrapping every/settings/*route. This duplicates the primarySidebar's own settings-related items on desktop — it earns its keep on mobile, where theSidebar's full nav lives behind the Sheet's hamburger and this is the only always-visible way to jump between settings sub-pages. Has its ownaria-label ="Settings"to disambiguate from the primary sidebar nav in tests (both render a same-named "Security" link otherwise).components/logo.tsx— the shared brand mark: a--primary-filled rounded tile with a chevron glyph in--primary-foreground, re-themes automatically. Used in both sidebars, the marketing header/footer, and the auth shell.components/navigation/marketing-header.tsx— the marketing shell's header (extracted fromapp/(marketing)/layout.tsx, which stays a plain Server Component, because the mobile Sheet needs local open state). Desktop navhidden md:flex; belowmd, a hamburgerSheet.app/(auth)/layout.tsx— a centeredCardwith a restrained radial--primaryglow behind it, wrapping every page underapp/(auth)/(login, signup, forgot/reset-password, accept-invitation).components/admin/*—user-row-actions.tsx(per-rowDropdownMenu+AlertDialogfor user ban/impersonate) andflag-toggle.tsx(aSwitchcallingsetFlag()directly) are the two admin-specific interactive components; everything else inapp/(admin)/admin/*is a Server Component page composingTable+Badgedirectly.
Notable patterns worth reusing
- Link styled as a button:
buttonVariants({ variant, size })applied directly to aLink'sclassName, never<Button render={<Link/>}>— Base UI'sButtonPrimitivedefaultsnativeButton: trueand warns when its render target isn't a real<button>. Used throughout (landing hero, pricing CTAs, billing's free-tier CTA, the admin waitlist's "Export CSV" link). - Dialog-with-a-reveal-step:
components/settings/api-keys-manager.tsxandwebhooks-manager.tsxboth use oneDialogthat transitions from a "name it" form step to a "here's your secret, copy it now" reveal step without closing in between (a piece of local state, e.g.freshKey, drives which step renders). Closing the dialog (Done/Escape/backdrop) clears that state, so a re-opened dialog can't accidentally re-show a stale secret. - DropdownMenu items that open an AlertDialog:
components/admin/ user-row-actions.tsx— aDropdownMenuItem'sonClicksets which confirmation dialog is open (useState<"ban" | "impersonate" | null>), rather than performing the action directly. The actual mutation stays a real<form action={fn.bind(null, id)}>Server Action inside the dialog, so progressive enhancement is preserved — only the trigger moved behind confirmation. Verified safe: menu-close-then-dialog-open in quick succession (two Base UI overlays back to back) was flagged as a focus-management risk at design time and turned out fine in practice. - Stretched-link cards:
app/(marketing)/blog/page.tsx's post list —Linkwraps only the<h2>title withafter:absolute after:inset-0, making the wholeCardclickable while the link's accessible name stays just the title, not the date/tag/description read out with it. display: contentsto nest a<form>inside a Dialog without breaking its layout:user-row-actions.tsx's ban confirmation wraps the entireAlertDialogContentbody (header, a reasonInput, footer) in<form className="contents">so the reason field can live inside the dialog.display: contentsremoves the form's own box from rendering; its children participate directly inAlertDialogContent's own grid layout (including the footer's edge-to-edge negative margins), as if the<form>tag weren't there at all.- A
Selectinside a plain GET<form>, no client JS added:app/(admin)/admin/audit/page.tsx's action filter.<Select name="action">renders a hidden, form-associated input the same way a native<select>would, so a plain<button type="submit">still produces a normal?action=...navigation — norouter.pushoronValueChange-triggered client-side filtering needed. - Server Actions called directly from client code, not through
<form action>: any action that doesn't readFormData(setFlag (key, enabled), mostauthClient.*calls) is just called as an async function inside auseTransition, same as calling any other async function — a<form>is only needed when the action's signature expectsFormDataas an argument.
Base UI vs Radix — API differences that bit real code
- No
asChild. Compose a trigger with a custom component viarender:<DialogTrigger render={<Button variant="outline" />}>Open </DialogTrigger>, not<DialogTrigger asChild><Button>...</Button> </DialogTrigger>. Accordionhas notype="single"/collapsibleprops — it's value-array-based (multiple?: boolean,defaultValue?: Value[]). Single-open-at-a-time is the default; no extra props needed.DropdownMenuLabelthrows withoutDropdownMenuGroup—Base UI: MenuGroupContext is missingif used standalone, unlike Radix's version. Always wrap:<DropdownMenuGroup><DropdownMenuLabel>... </DropdownMenuLabel>...</DropdownMenuGroup>.<Button render={<Link/>}>warns ("A component that acts as a button expected a native<button>") — see "Link styled as a button" above for the actual fix.Select.Valuedoesn't auto-derive its label from the matchingSelect.Itemthe way Radix's does. With noitemsprop on<Select>(the Root), it silently renders the rawvalueinstead of that item's display text — no error, no warning, just wrong-looking text in the trigger the moment the value and its label differ (an id vs. a name, a short code vs. its full label). Fix: passitems={{value: label}}(or an array of{value, label}) to<Select>. Affectsorg-switcher.tsx,invite-form.tsx, andmembers-table.tsx's role pickers.- shadcn CLI footgun:
npx shadcn add <component>can silently overwrite an already-customized dependency file.npx shadcn add dialogonce reverted a customizedbutton.tsxback to pristine upstream, wiping theloadingprop and<SubmitButton>. Always rungit status/git diffimmediately after anyshadcn addand restore customized files if needed (git checkout -- <file>if already committed).
RSC and "use client" boundaries
- A
"use client"file poisons every export, including pure ones.components/ui/button.tsxoriginally carried a file-level"use client"onlySubmitButton(viauseFormStatus()) actually needed — but that madebuttonVariants, a purecvafunction with zero browser dependency, uncallable from a Server Component. Calling it fromapp/(marketing)/page.tsxcrashed the build (not dev mode) with "Attempted to call buttonVariants() from the server but buttonVariants is on the client." Fixed by splittingSubmitButtoninto its own file with its own"use client", and removing the directive frombutton.tsx— it doesn't need one,@base-ui/react/buttondeclares its own client boundary internally. Rule of thumb: any file exporting both a pure helper and something hook-based should split the hook-based piece out, or the pure export becomes unusable from Server Components.npm run devwill not catch this class of bug — onlynpm run builddoes, which is why build is a mandatory step in this repo's verification checklist, not an optional extra. - Icons must be rendered server-side, not passed as component
references.
SidebarNavItem.iconis typedReactNode(a rendered<ShieldCheck />), notLucideIcon(the component reference). Both layouts building the item list are Server Components handing data to a clientSidebar— passing the bare icon function crashes with "Functions cannot be passed directly to Client Components." RSC can serialize already-rendered elements across that boundary, not function/component values sitting inside plain prop data. - Don't re-export a bare type from a
"use server"file, even one nothing imports.lib/actions/webhooks.tshad a deadexport type { WebhookEvent }that nothing consumed — Next's server-actions compiler doesn't handle a type-only re-export cleanly in a"use server"file, and generated a client-side action reference pointing at a value that doesn't exist at runtime. Every call tocreateWebhookEndpointthrewReferenceError: WebhookEvent is not defined. This shipped silently broken because nothing had e2e coverage on that flow;npm run builddidn't catch it either — only an actual POST through the action surfaces it.
Verifying interactive components
The manual browser tool available in this environment has two specific limitations that read as component bugs but aren't:
- Its pane runs with
document.hidden = true(not composited), which freezes CSS animation timelines — anything that unmounts onanimationend(Dialog, Sheet, …) looks "stuck open" after closing. Checkaria-expanded/data-*state instead of DOM presence, or verify via Playwright. - Its
left_clickdoesn't always reproduce the press/release timing Base UI's open-on-mousedown components (Menu, DropdownMenu, Select) expect — clicks can silently no-op.
For anything overlay/menu/select-shaped, a throwaway Playwright spec
(e2e/scratch-*.spec.ts, deleted before committing — never part of the
permanent suite) is the reliable way to verify. This caught several real
bugs across this migration that manual clicking either missed or wrongly
attributed to "the tool being flaky" — the DropdownMenuGroup crash, the
Select label bug, and the dead-type-export ReferenceError above were
all found this way, and each fix was re-verified the same way before the
scratch spec was deleted.
One non-obvious Playwright finding worth keeping: browser .newContext() inherits the enclosing describe's test.use({ storageState }) as its own default. Creating a genuinely logged-out
second context (to test a second concurrent session, for example)
requires passing storageState: undefined explicitly — calling
newContext() with no arguments still inherits the logged-in state.
Rate-limit false alarms: both lib/actions/contact.ts's limiter
(5/hour, Postgres-backed action_rate_limits) and better-auth's own
sign-in limiter (3 attempts/10s, Postgres-backed rateLimit table) can
trip purely from re-running e2e suites repeatedly in one sitting — both
survive server restarts since they're DB-backed, not in-memory. If a
contact-form or login e2e test fails with the submission never landing,
check those tables (DELETE FROM action_rate_limits WHERE key LIKE '%contact%' / DELETE FROM "rateLimit" WHERE key LIKE '%sign-in%')
before assuming the UI broke.
Full verification pass (clean install, Docker, keyboard/focus)
Beyond per-task checks (typecheck/lint/unit/e2e/build on every commit), this migration's final pass also covered:
- Clean
npm ci(rm -rf node_modules && npm ci) — passes; the dependency list added across this migration doesn't introduce any newERESOLVEconflict beyond the pre-existing better-auth one the Dockerfile and CI already work around. RUN_DB_TESTS=1 vitest run(the DB-gated integration suite, otherwise skipped) — all pass.docker build— found and fixed a real bug this way: deleting the five unused stock SVGs frompublic/left the directory completely empty, and git doesn't track empty directories — so on a fresh clone/checkout,public/doesn't exist at all, and the Dockerfile'sCOPY --from=builder /app/public ./publicstep fails with "file does not exist".npm run buildnever caught this (Next doesn't requirepublic/to exist), so it shipped silently broken until the Docker build step specifically surfaced it. Fixed with a trackedpublic/.gitkeep. Rule of thumb: deleting every file from a directory that aDockerfile/build step references by path can break that step even when nothing else does — grep for the directory name inDockerfile/CI config, not just application code, before assuming a directory-emptying change is safe.- Keyboard/focus spot-check on
AlertDialog,Dialog, andDropdownMenu(Tab-cycling within the dialog with a disabled button correctly excluded from the tab order, Escape closing and returning focus to the trigger,DropdownMenuopened viaEnterand navigated viaArrowDown) — all correct. One Playwright-specific timing note: checkingdocument.activeElementsynchronously right afterpage.keyboard.press("Tab")can observe a transient intermediate state, since Base UI's focus-guard redirect (what keeps Tab inside an open dialog) runs on a microtask after the native Tab-driven focus event, not synchronously within it. A first pass without any wait reported the trap as broken; adding a short settle delay (or using Playwright's own auto-retryingexpect(locator).toBeFocused(), the cleaner fix) before readingactiveElementshowed the trap working correctly the whole time. Worth knowing before concluding a focus trap is broken from a synchronous check.
How to extend it
Vendor a new component: npx shadcn add <name> (works against this
fork — verified repeatedly). Immediately check git status/git diff for
unintended changes to already-customized dependency files (see the CLI
footgun above). If the CLI ever fails to detect the project, copy the
component source from ui.shadcn.com's registry by hand — these are plain
files with no CLI-only magic, just strip any forwardRef usage (this repo
pins React 19, where ref is a plain prop).
Add a new page onto this system: reach for Card/CardContent as the
default container, Table/TableHeader/TableBody/TableRow/
TableCell for any list of records, Badge for status/tag display, and
gate every destructive action behind an AlertDialog (never a bare
button, never a hand-rolled two-step inline confirm). For any form field,
pair an Input/Textarea/Select/Checkbox/Switch with a real
Label, not a raw <span> or a placeholder standing in for one.
Extend the token contract: add the new CSS custom property to both
:root and .dark in app/globals.css, then register it as a Tailwind
utility in the @theme inline block (--color-<name>: var(--<name>)) —
that's what makes bg-<name>/text-<name>/etc. exist as real utility
classes rather than requiring an arbitrary-value [var(--<name>)] at
every call site. The --warning/--warning-foreground pair is the
existing example to copy.
How to remove it
Every vendored components/ui/*.tsx file is self-contained — no shared
runtime dependency beyond cn() and @base-ui/react itself — so
reverting is per-component, not all-or-nothing: delete the file, remove
its imports, and replace call sites with plain Tailwind-styled native
elements (<button>, <select>, <table>, …).
To drop dark-mode theming and go back to prefers-color-scheme-only:
remove components/theme-provider.tsx's usage from app/layout.tsx,
delete components/theme-toggle.tsx and its mount points, remove
next-themes, and change app/globals.css's @custom-variant dark (&:is(.dark *)) back to @media (prefers-color-scheme: dark).
To drop Base UI/shadcn entirely and go back to fully hand-rolled
primitives: delete components/ui/, components.json, the shadcn
devDependency, @base-ui/react, and class-variance-authority; keep
clsx/tailwind-merge/cn() if you still want a Tailwind-class-merging
utility (independently useful, not shadcn-specific). Every consumer
importing from @/components/ui/* needs a replacement — there are many
(button, input, card, table, dialog, dropdown-menu, alert-dialog, select,
switch, checkbox, badge in particular are used across marketing, auth,
app, and admin) — this is the highest-effort removal path and not
recommended unless you have a specific reason to avoid Base UI as a
dependency.
To keep the design system but change the brand: see "How to configure it" above — this is the common case and doesn't require removing anything.