Skip to content

App design system

This page is the single source of truth for what apps built on Turbobase look like. Every app template links here instead of inlining the rules — so the order-management demo, the expense-management demo, and anything new you ship stay visually coherent without copy-pasting 80 lines of style guidance into each prompt.

turbobase apps init ships most of this stack. A scaffolded project already has React 19, Tailwind v4 (as a Vite plugin — there is no tailwind.config.js; the theme lives in src/styles.css), shadcn/ui in New York style on the neutral base with components.json set up so npx shadcn@latest add <component> works immediately, six of its primitives already written out (button, card, badge, table, input, label), lucide-react, and self-hosted Inter.

The four libraries named below that a starter app does not need are not installed: react-hook-form, zod, sonner and date-fns. Add them when you reach for them — the point of naming them here is that when you do need a form, a toast or a date, these are the ones to pick, so every app on the platform reaches for the same four.

If you are writing a new template prompt, the prompt should read: “Before writing any UI code, read https://docs.turbobase.io/build-apps/design-system/ and apply it.” Then the prompt only adds the template-specific rules (status pill palette for that domain, KPI tiles, etc.).

This page covers how an app looks. It does not cover the rules that stop a screen showing the wrong data — rendering an id where a name exists, an empty list that is really a failed query, a sync timestamp presented as a business date. Those live in the app-design skill in your project (.claude/skills/app-design/SKILL.md) and in Build apps.

Looks like a real SaaS product on first paint, not a generated demo. The reference points are Linear, Notion, Stripe Dashboard — products where density and restraint do the heavy lifting and color is rationed. Avoid the AI-generated tells: gradient backgrounds, glassmorphism, oversized rounded corners, candy palettes, animated mesh blobs.

  • Framework: Vite + React + TypeScript (static SPA), React 19 stable (do not pin RC).
  • Styling: Tailwind v4.
  • Components: shadcn/ui, New York style, neutral base color.
  • Forms: react-hook-form + zod.
  • Icons: lucide-react.
  • Toasts: sonner.
  • Dates: date-fns.
  • Font: Inter, self-hosted via @fontsource/inter (or a <link> to Google Fonts). Headings get tracking-tight.

Do NOT add: Redux, tRPC, Prisma, NextAuth, a UI kit other than shadcn, chart libraries unless the template explicitly needs more than hand-rolled SVG.

The one carve-out: Konsta, on a phone surface

Section titled “The one carve-out: Konsta, on a phone surface”

That list stands. The single exception is Konsta, and only when the app has a genuine phone-first surface: a handheld or scanner workflow, a field or warehouse task, or a prompt that explicitly asks for mobile. No trigger, no Konsta — an app that would merely also look fine on a phone does not qualify, and reaches the phone through the responsive floor below instead. Everything else is shadcn, and shadcn only.

The phone surface is a different product, not the desktop reflowed. That single instruction separates a good implementation from a bad one: different navigation (a tab bar, not a sidebar), different density (one task per screen, not a table of twelve columns), different targets. A desktop table squeezed into 390px is not a mobile surface.

Four conditions. Each one ships silently broken on its own — a green build and a wrong screen:

  1. React 19 is required. Konsta 5 declares no react peer dependency, so npm installs it against React 18 with no warning at all. Its components take ref as a plain prop — the React 19 API; there is not one forwardRef in the package — and React 18 strips ref out of props before the component ever sees it. Anything that MEASURES (a segmented control positioning its highlight, a popover finding its anchor) gets nothing back and degrades silently: tsc --noEmit is clean, vite build is green, the component is inert.
  2. Tailwind must scan the whole package — @source "../node_modules/konsta/**/*.js" in src/styles.css. The whole package, not the entry. konsta/react resolves to react/konsta-react.js, which only re-exports; the class strings are built in shared/classes/*.js. Point Tailwind at the entry alone and it compiles enough to look nearly right while every inset, rounding and width class is silently missing.
  3. Override Konsta’s Material colour scale, or it tints the rest of the app — it ships its own palette and left alone that palette wins. Map it onto the accent this template already picked. Pick ONE Konsta theme (ios or material) at build time; it is not a user-facing toggle and never a prefers-color-scheme branch.
  4. On that surface Konsta REPLACES shadcn. The two do not sit side by side — one screen, one kit, or you get two type scales in one view and two components fighting over the same focus. components.json stays exactly as it is: the desktop and tablet surfaces are still shadcn.
  • Background: white. Light mode only — no dark default, no gradients on the body.
  • Surface: cards are bg-card (which maps to white) with border border-border.
  • Accent: pick ONE per template — violet, emerald, blue, or rose. Use it for the primary CTA, focused inputs, active nav, and one or two key numbers per screen. Everything else stays neutral.
  • Danger / overdue: rose. Success / paid: emerald. Pending / draft: amber. Closed / inactive: zinc. In progress / approved: blue.
  • Text hierarchy: text-foreground for headings + primary numbers, text-muted-foreground for everything else. No more than two text colors per card.
  • Spacing scale: multiples of 4. Card padding p-5 (sm) / p-6 (md+). Gap between cards gap-6. Sections separated by space-y-8.
  • Border radius: rounded-lg for cards, rounded-md for buttons + inputs, rounded-full only for status pills and avatar circles. No rounded-2xl or rounded-3xl.
  • Borders: border border-border (subtle). Never border-2. Tables get border-b border-border between rows, no vertical borders.
  • Shadows: shadow-sm on cards. No shadow-xl, no glow.
  • H1 (page title): text-2xl font-semibold tracking-tight (sm:text-3xl). One per page.
  • H2 (section): text-lg font-semibold.
  • Body: text-sm for content, text-xs for metadata.
  • KPI numbers: text-2xl font-semibold tabular-nums. Currency lines use tabular-nums everywhere they appear in tables.
  • Eyebrows: tiny uppercase pills — text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground. Used sparingly above section titles.

h-14, border-b border-border, bg-background, brand on the left (the Turbobase wordmark + a tiny accent dot — <h1 class="font-semibold tracking-tight">Turbobase</h1> with <span class="ml-2 h-1.5 w-1.5 rounded-full bg-violet-500"></span>), user menu on the right. No theme toggle (the app is light-mode only). No drop-shadow. No backdrop blur.

Page title + a one-line description below it (text-sm text-muted-foreground), primary CTA right-aligned. If the page has tabs, they sit immediately under the header in a border-b border-border rail, not a pill group.

A short label or one clause. That is the budget, and it applies to every surface on the screen — the line under the page title, a section intro, a note beside a control.

Anything longer goes behind an info icon opening a modal: a variant="ghost" icon button with lucide Info and an aria-label, opening a shadcn Dialog. The reader who wants the explanation gets all of it; the reader who does not gets their data. A multi-sentence paragraph under every heading, or a full-width card of explanation sitting below the table, is the tell of a screen that has not decided what it is for — it pushes the actual content below the fold and reads as filler even when it is accurate.

Dialog is already sanctioned, and the modal rule in Accessibility floor already applies to it: labeled close button, focus trapped.

Four tiles across at lg, stacks below. Each tile: tiny eyebrow → big number (tabular-nums) → a delta line showing ▲ 12% vs last 30d in text-xs. Up = emerald-500, down = rose-500, flat = muted-foreground. No sparklines inside the KPI — those go in a dedicated card.

shadcn <Table>. Header row text-xs uppercase tracking-wider text-muted-foreground. Body rows hover:bg-muted/40. No zebra striping. Money columns right-aligned, tabular-nums. First column is the human-readable identifier (tran_id, expense report number, customer name), last column is the action (status pill or kebab menu). Designed empty state: lucide icon + one line of copy + (where applicable) a primary CTA.

Always show the ISO 4217 currency code (AUD, USD, INR), never a source-system display name or symbol-name. This matters because the canonical *_currency_name denorm on a row carries whatever the source labels the currency (Oracle NetSuite, for instance, stores name as a free-text label, so you’ll see "AUS", "US Dollar", "Canadian Dollar" in the same column). Do not render currency_name: it is inconsistent and not a code.

The reliable code lives on the turbobase.currency lookup, whose symbol column is the ISO 4217 code:

currency.external_id → matches the row's `currency_id`
currency.symbol → ISO 4217 code (AUD, USD, INR …) ← display THIS
currency.name → source label (may be "AUS") ← do NOT display

So you resolve the code by mapping the row’s currency_id to a currency row’s symbol. Default to a scalar select + a separate lookup — do NOT embed currency into a query that has to succeed.

The tempting one-liner is a PostgREST embed (?select=...,currency_id,currency:currency_id(symbol)). It looks clean, but it silently couples the query to a declared FK relationship <table>.currency_id → currency that may not exist on a given instance. When the FK is absent, PostgREST doesn’t return a null embed — it 400s the entire request, so a decorative currency symbol takes down the whole read (the employee fetch returns nothing, the dashboard shows “no record”). An embed on a must-succeed query is a latent 400.

The robust pattern, which never blocks the primary read:

# 1. Primary read — scalar columns ONLY, including currency_id:
?select=...,total,currency_id
# 2. Resolve the symbol once, best-effort, in a SEPARATE lookup.
# Either fetch the whole small list (~12 rows) and map client-side …
GET /rest/v1/currency?select=external_id,symbol&is_inactive=eq.false
# … or look up the one code you need:
GET /rest/v1/currency?external_id=eq.<currency_id>&select=symbol&limit=1

Build a currency_id → symbol map from step 2 and render map[row.currency_id] ?? row.currency_id (fall back to the raw id, never to currency_name). If step 2 fails or returns nothing, the amount still renders — you’ve just lost the prettier code, not the row.

Format the amount with Intl.NumberFormat(undefined, { style: 'currency', currency: symbol }) — passing the ISO code makes Intl place the correct symbol and decimals automatically.

Embed only if you’ve confirmed the FK exists on the live instance. currency:currency_id(symbol) is fine in a query whose failure is acceptable (a purely decorative read you’ve verified against the live catalog/relationships), but it must never sit on a required-path query — the employee/identity fetch, the dashboard’s hero read, anything whose null shows the user a broken screen. See the integration-assumptions rule in Build apps on Turbobase.

This is about guessing at an FK on a lookup column. It does not apply to a header’s line table (item_receipt_line(…)), which is a real foreign key present on every instance, or to a relationship your app declared — both are safe on a required-path query. See Reading the mirror.

No chart library — hand-roll small dashboard charts (a 6-month bar trend, a 14-day sparkline) as a single inline <svg>. To size correctly at any viewport width:

  • Render responsively via viewBox, not pixel coordinates. Give the <svg> a fixed coordinate space — viewBox="0 0 720 240" (pick an aspect that suits the card) plus preserveAspectRatio="xMidYMid meet" — and let it fill the card width with class="w-full" (width: 100%). Compute every bar, gridline, and label position in the viewBox coordinate space (the 720×240 units), never from the rendered pixel width. The chart then lays out identically whether the card is 480px or 1200px wide.
  • Bound the height. Cap the chart area with a fixed Tailwind height on the SVG’s wrapper (e.g. h-56 / h-64, ≈ 224–256px) so the plot doesn’t balloon or scale its height with width on wide desktops. Height is fixed; only width is fluid.
  • Cap the card. On very wide screens, constrain the chart card (e.g. max-w-* on the inner SVG wrapper, or let the card sit in the dashboard grid) so the plot area doesn’t grow unbounded.
  • Bars/columns use the accent at a muted opacity (fill-current text-{accent}-500/80); gridlines and axis labels are text-muted-foreground. Hover shows the data point’s label + value (a <title> element per bar is the zero-dependency tooltip).
  • Stacked layout. Two columns only when the screen is lg+ AND fields are short (date pickers, dropdowns).
  • Label above input, helper text below in text-xs text-muted-foreground. Errors replace helper text in text-rose-500.
  • Required fields get a * in the label, not a “(required)” suffix.
  • Submit button anchored to a sticky right-rail summary card on multi-line forms (line items, expense lines) so the user can submit without scrolling.
  • Inline dynamic field arrays (lines): each row is a single flex items-end gap-3, trash button on the right is a ghost icon button. “Add line” is a variant="ghost" button with a Plus icon, left-aligned under the last row.

Always a rounded-full px-2 py-0.5 text-xs font-medium with a tinted background. These are the light-mode colors (the app is light-mode only):

State Background Text
draft / pending bg-amber-500/10 text-amber-600
submitted / in review bg-blue-500/10 text-blue-600
approved / paid / synced bg-emerald-500/10 text-emerald-600
rejected / overdue bg-rose-500/10 text-rose-600
closed / inactive bg-zinc-500/10 text-zinc-600

Templates can extend the palette, but the shape and sizing don’t change.

Every list, table, and dashboard card has a designed empty state — never a blank box. The pattern is centered lucide icon (24px, text-muted-foreground), one line of copy (“No expenses yet — submit your first report”), and an optional primary CTA. Use it on first paint too: an empty dashboard for a new instance should feel intentional, not broken.

shadcn <Skeleton> shaped like the eventual content. KPI tile skeletons match the tile height. Table skeletons render 3–5 rows. Never use a generic centered spinner on the page body — only inside button labels and inline indicators.

  • Field-level errors: inline, text-rose-500 text-xs.
  • Mutation errors: sonner toast with a “Retry” action where it makes sense.
  • Page-load errors: a centered card with the message and a “Reload” button. Never throw a raw stack trace at the user.

Generated apps render in light mode, always. No dark default, no next-themes, no theme toggle, no class="dark" on <html>, no prefers-color-scheme branch, no theme cookie. Don’t ship the dark palette at all — keep the shadcn neutral light defaults.

  • Card mounts: none. Page transitions: none.
  • Realtime row flash: a single bg-emerald-500/10 background for ~1.5s, then transition-colors settles. That’s the one visual flourish that earns its keep.
  • Hover transitions on buttons and links: transition-colors duration-150.
  • Toasts slide in from the top-right corner (sonner default). Position: top-right, NOT bottom-center.
  • Every interactive element has a visible focus ring (shadcn defaults are good — don’t override).
  • Color is never the only signal: status pills also carry text, deltas also carry ▲ / ▼.
  • Every screen is usable at 390px, 834px and 1280px, with no horizontal scroll at any of them. Phone, tablet, desktop — check all three before calling a screen done, forms included. A layout verified only at desktop width has not been checked on a phone at all; the three widths are the floor, not three designs.
  • Never ship a hidden md:block / md:hidden layout pair. Both trees render into the DOM: every input in the “hidden” one stays tabbable, its effects keep running, and two components fight over the same focus and the same ids. Reflow the ONE tree with grid/flex and responsive utilities. If a surface genuinely needs a different structure per breakpoint, it is a different product (see the Konsta carve-out) and it gets its own route, not a display toggle.
  • Modal / Sheet always has a labeled close button and traps focus.
  • No hero gradients, no decorative animated backgrounds.
  • No emoji in production UI copy.
  • No “Powered by Claude” / “Built with AI” / similar badges.
  • No marketing landing page on /. The first authenticated route is the dashboard, and the unauthenticated / redirects to the instance’s hosted sign-in page (there is no in-app /login page).
  • No :root { --radius: 1rem; } style overrides — keep the shadcn neutral defaults so the templates look like the same product.

Things the prompt SHOULD declare (because they vary by template):

  • Which accent color out of the four.
  • Status pill states specific to the record (orders have pending_fulfillment, expenses have submitted, etc.).
  • KPI tile contents and the time window.
  • The single hero card the dashboard leads with.
  • Whether the app has a phone surface at all, and which workflow lives on it. This is the door the Konsta carve-out comes through: a template whose prompt does not name that surface is shadcn-only, and adding Konsta to it is the same mistake as adding Redux.