Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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:

VariableRole
--primaryFill color for primary buttons/badges, and — since nothing else overrides it — link/accent text color
--primary-foregroundText color on a --primary-filled surface (must stay AA-contrasted against --primary, not against the page background)
--ringFocus-visible ring color; conventionally matches --primary
--chart-1First 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 library lucide. Lets npx shadcn add <component> vendor new components consistent with what's already here.
  • lib/utils.ts's cn(...) — merges Tailwind classes via clsx + 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 !important in a couple of places).
  • Primitive engine: @base-ui/react (MUI's headless component library), not @radix-ui/* — shadcn's current default engine for new shadcn add output. Components built on it use a render/prop- forwarding pattern instead of Radix's asChild. See "Base UI vs Radix" below for the specific API differences this surfaced.
  • shadcn npm package (a devDependency — build-time CLI + a shared Tailwind CSS import, never imported from application runtime code) provides shadcn/tailwind.css, imported once from app/globals.css: shared @custom-variant rules for Base UI's data-* 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-foreground pair (amber), used for banners/alerts that are "notable, not broken" (impersonation/trial banners, past_due billing alerts — see observability.md, billing.md). @custom-variant dark (&:is(.dark *)) switches every token from a .dark class on <html>, not prefers-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.tsx wraps app/layout.tsx's children; attribute="class" stamps .dark/.light on <html>. components/theme-toggle.tsx is a plain native <button> (not components/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 in components/theme-provider.tsx; there's no fix that keeps zero-FOUC theming and avoids this.
  • Shiki dual-theme code blocks — components/mdx/mdx-content.tsx passes rehype-pretty-code themes: { 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 sets color/background-color itself. app/globals.css consumes them: pre[data-theme] (and its dark counterpart) apply background-color/color from 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 — variants default/outline/secondary/ghost/ destructive/link; sizes default/xs/sm/lg + icon/icon-xs/icon-sm/icon-lg. loading (spinner + auto-disable) and <SubmitButton> (reads useFormStatus() for useActionState/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. SubmitButton lives in its own file, submit-button.tsx, re-exported from button.tsx — see "RSC and 'use client' boundaries" below for why.
  • input.tsx, textarea.tsx, label.tsx — straightforward vendor. Input is Base UI-backed; Textarea stays a plain native <textarea> (Base UI doesn't wrap it).
  • select.tsx, checkbox.tsx, switch.tsx — see "Base UI vs Radix" below for Select's one real gotcha (items prop required for correct label display).
  • card.tsx, badge.tsx, separator.tsx, skeleton.tsx, table.tsx, tabs.tsx, avatar.tsx, tooltip.tsx — Card replaced two competing pre-migration "bordered box" conventions and several locally-declared Card/Stat helper functions reimplementing the same thing; Table replaced 5 hand-copied <table> shells across the admin pages. TooltipProvider is mounted once in app/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 to next-themes ( useTheme(), so toasts follow light/dark) and to this repo's tokens (--normal-bg: var(--popover), etc.) — no hand-tuned colors. <Toaster /> in app/layout.tsx replaces the old <ToastProvider>. Callers use import { toast } from "sonner" + toast.success(...)/toast.error(..., { description })/plain toast(...).
  • spinner.tsx — the one pre-existing hand-rolled primitive kept as-is: it already used currentColor throughout, so there was nothing token-specific to migrate.

Shells

  • components/navigation/sidebar.tsx — the single shared shell for both app/(app)/layout.tsx and app/(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 via usePathname() exact match (aria-current="page" + a --sidebar-accent background). Desktop shows a fixed <aside>; below md, a topbar with a hamburger Sheet renders the same SidebarContent (brand, org switcher, nav, user menu) in a slide-over — not a stripped-down mobile menu. User menu is an avatar + email DropdownMenu with sign-out; a separate always-visible ThemeToggle sits 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 via app/(app)/settings/layout.tsx, wrapping every /settings/* route. This duplicates the primary Sidebar's own settings-related items on desktop — it earns its keep on mobile, where the Sidebar'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 own aria-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 from app/(marketing)/layout.tsx, which stays a plain Server Component, because the mobile Sheet needs local open state). Desktop nav hidden md:flex; below md, a hamburger Sheet.
  • app/(auth)/layout.tsx — a centered Card with a restrained radial --primary glow behind it, wrapping every page under app/(auth)/ (login, signup, forgot/reset-password, accept-invitation).
  • components/admin/* — user-row-actions.tsx (per-row DropdownMenu + AlertDialog for user ban/impersonate) and flag-toggle.tsx (a Switch calling setFlag() directly) are the two admin-specific interactive components; everything else in app/(admin)/admin/* is a Server Component page composing Table + Badge directly.

Notable patterns worth reusing

  • Link styled as a button: buttonVariants({ variant, size }) applied directly to a Link's className, never <Button render={<Link/>}> — Base UI's ButtonPrimitive defaults nativeButton: true and 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.tsx and webhooks-manager.tsx both use one Dialog that 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 — a DropdownMenuItem's onClick sets 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 — Link wraps only the <h2> title with after:absolute after:inset-0, making the whole Card clickable while the link's accessible name stays just the title, not the date/tag/description read out with it.
  • display: contents to nest a <form> inside a Dialog without breaking its layout: user-row-actions.tsx's ban confirmation wraps the entire AlertDialogContent body (header, a reason Input, footer) in <form className="contents"> so the reason field can live inside the dialog. display: contents removes the form's own box from rendering; its children participate directly in AlertDialogContent's own grid layout (including the footer's edge-to-edge negative margins), as if the <form> tag weren't there at all.
  • A Select inside 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 — no router.push or onValueChange-triggered client-side filtering needed.
  • Server Actions called directly from client code, not through <form action>: any action that doesn't read FormData (setFlag (key, enabled), most authClient.* calls) is just called as an async function inside a useTransition, same as calling any other async function — a <form> is only needed when the action's signature expects FormData as an argument.

Base UI vs Radix — API differences that bit real code

  • No asChild. Compose a trigger with a custom component via render: <DialogTrigger render={<Button variant="outline" />}>Open </DialogTrigger>, not <DialogTrigger asChild><Button>...</Button> </DialogTrigger>.
  • Accordion has no type="single"/collapsible props — it's value-array-based (multiple?: boolean, defaultValue?: Value[]). Single-open-at-a-time is the default; no extra props needed.
  • DropdownMenuLabel throws without DropdownMenuGroup — Base UI: MenuGroupContext is missing if 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.Value doesn't auto-derive its label from the matching Select.Item the way Radix's does. With no items prop on <Select> (the Root), it silently renders the raw value instead 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: pass items={{value: label}} (or an array of {value, label}) to <Select>. Affects org-switcher.tsx, invite-form.tsx, and members-table.tsx's role pickers.
  • shadcn CLI footgun: npx shadcn add <component> can silently overwrite an already-customized dependency file. npx shadcn add dialog once reverted a customized button.tsx back to pristine upstream, wiping the loading prop and <SubmitButton>. Always run git status/git diff immediately after any shadcn add and 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.tsx originally carried a file-level "use client" only SubmitButton (via useFormStatus()) actually needed — but that made buttonVariants, a pure cva function with zero browser dependency, uncallable from a Server Component. Calling it from app/(marketing)/page.tsx crashed the build (not dev mode) with "Attempted to call buttonVariants() from the server but buttonVariants is on the client." Fixed by splitting SubmitButton into its own file with its own "use client", and removing the directive from button.tsx — it doesn't need one, @base-ui/react/button declares 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 dev will not catch this class of bug — only npm run build does, 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.icon is typed ReactNode (a rendered <ShieldCheck />), not LucideIcon (the component reference). Both layouts building the item list are Server Components handing data to a client Sidebar — 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.ts had a dead export 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 to createWebhookEndpoint threw ReferenceError: WebhookEvent is not defined. This shipped silently broken because nothing had e2e coverage on that flow; npm run build didn'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:

  1. Its pane runs with document.hidden = true (not composited), which freezes CSS animation timelines — anything that unmounts on animationend (Dialog, Sheet, …) looks "stuck open" after closing. Check aria-expanded/data-* state instead of DOM presence, or verify via Playwright.
  2. Its left_click doesn'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 new ERESOLVE conflict 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 from public/ 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's COPY --from=builder /app/public ./public step fails with "file does not exist". npm run build never caught this (Next doesn't require public/ to exist), so it shipped silently broken until the Docker build step specifically surfaced it. Fixed with a tracked public/.gitkeep. Rule of thumb: deleting every file from a directory that a Dockerfile/build step references by path can break that step even when nothing else does — grep for the directory name in Dockerfile/CI config, not just application code, before assuming a directory-emptying change is safe.
  • Keyboard/focus spot-check on AlertDialog, Dialog, and DropdownMenu (Tab-cycling within the dialog with a disabled button correctly excluded from the tab order, Escape closing and returning focus to the trigger, DropdownMenu opened via Enter and navigated via ArrowDown) — all correct. One Playwright-specific timing note: checking document.activeElement synchronously right after page.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-retrying expect(locator).toBeFocused(), the cleaner fix) before reading activeElement showed 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.