This document provides a comprehensive guide for understanding and customizing the theming system in LibreDB Studio.
LibreDB Studio uses a modern theming architecture built on:
- Tailwind CSS v4 - CSS-first configuration with
@themedirective - shadcn/ui - Accessible component library with CSS variable theming
- CSS Custom Properties - Light and dark variable sets, in two layers: the shadcn
variables in
globals.cssand studio's own semantic tokens insrc/styles/theme.css
Studio is dark-first with a runtime light theme: next-themes writes the dark class,
the toggle in the header flips it, and the choice persists under the libredb-theme storage
key. Dark is the default and the server-rendered assumption.
globals.css src/styles/theme.css (shipped as dist/styles.css)
│ │
├── :root (shadcn light) ├── :root (studio light tokens)
├── .dark (shadcn dark) ├── .dark (studio dark tokens)
│ │
└── @theme inline └── @theme inline
│ │
└── bg-background, … └── bg-surface, text-fg-muted,
border-hairline, …
src/
├── app/
│ └── globals.css # shadcn variables + app-level global rules; imports theme.css
├── styles/
│ └── theme.css # studio's semantic tokens — the only place a surface colour is written
├── components/
│ ├── theme-provider.tsx # next-themes provider (class attribute, storageKey libredb-theme)
│ └── theme-toggle.tsx # two-state dark ↔ light control
└── hooks/
└── use-effective-theme.ts # the theme in force, for canvases that cannot read CSS
The shadcn variables cover the primitives; studio's own chrome — panels, rails, grids, the
editor frame — is written in the semantic tokens of src/styles/theme.css. Two ramps:
| Ramp | Tokens (recessed → elevated / brightest → faintest) |
|---|---|
| Surface | canvas · sunken · surface · raised · overlay (plus panel, the translucent card ground) |
| Text | fg-bright · fg · fg-secondary · fg-tertiary · fg-muted · fg-subtle · fg-faint |
Alongside them: hairline / hairline-strong for structural rules, edge / edge-hover for
the border of a control the user is meant to see, and fill-subtle / fill / fill-strong
for hover, selected and inset grounds. They are consumed as ordinary utilities —
bg-surface, text-fg-muted, border-hairline.
In dark, elevation means lighter; in light it means whiter, and the text ramp inverts around
fg-muted (zinc-500), the one value that reads on both grounds. The dark values reproduce the
literals the components carried before the layer existed, so moving a component onto a token
must be a no-op in dark — any visible dark-mode change is a bug unless it is deliberate and
called out.
Monaco, Recharts and the @xyflow ER diagram paint their own canvas from a JS palette, so they
cannot resolve a token. They read useEffectiveTheme() instead, which observes the dark
class on <html> rather than calling useTheme() — that class is where next-themes writes
studio's choice and where an embedding host writes its own, so one source answers both
deployments and an embedded studio needs no provider to follow along.
globals.css is not packaged, so an app consuming @libredb/studio must import the tokens
itself or every var(--studio-*) resolves to nothing:
import "@libredb/studio/styles.css";See docs/TOOLCHAIN.md for how that file is staged into dist/ and what
guards it.
| Variable | Description | Usage |
|---|---|---|
--background |
Page background color | bg-background |
--foreground |
Default text color | text-foreground |
--card |
Card/panel background | bg-card |
--card-foreground |
Card text color | text-card-foreground |
--popover |
Popover/dropdown background | bg-popover |
--popover-foreground |
Popover text color | text-popover-foreground |
--primary |
Primary action color | bg-primary, text-primary |
--primary-foreground |
Text on primary | text-primary-foreground |
--secondary |
Secondary action color | bg-secondary |
--secondary-foreground |
Text on secondary | text-secondary-foreground |
--muted |
Muted/subtle background | bg-muted |
--muted-foreground |
Muted text color | text-muted-foreground |
--accent |
Accent/hover background | bg-accent |
--accent-foreground |
Text on accent | text-accent-foreground |
--destructive |
Destructive action color | bg-destructive |
--destructive-foreground |
Text on destructive | text-destructive-foreground |
--border |
Border color | border-border |
--input |
Input border color | border-input |
--ring |
Focus ring color | ring-ring |
--radius |
Border radius base | rounded-lg, rounded-md |
| Variable | Light (:root) |
Dark (.dark) |
Usage |
|---|---|---|---|
--chart-1 |
#e76e50 |
#3b82f6 |
Primary chart color |
--chart-2 |
#2a9d90 |
#22c55e |
Secondary chart color |
--chart-3 |
#274754 |
#f59e0b |
Tertiary chart color |
--chart-4 |
#e8c468 |
#a855f7 |
Quaternary chart color |
--chart-5 |
#f4a462 |
#ec4899 |
Quinary chart color |
LibreDB Studio uses a dark-first design with the following color palette (based on Tailwind Zinc):
.dark {
--background: #09090b; /* zinc-950 */
--foreground: #fafafa; /* zinc-50 */
--card: #0a0a0a; /* near zinc-950 */
--popover: #0a0a0a;
--secondary: #27272a; /* zinc-800 */
--muted: #27272a; /* zinc-800 */
--accent: #27272a; /* zinc-800 */
--border: #27272a; /* zinc-800 */
--muted-foreground: #a1a1aa; /* zinc-400 */
}The layout wraps the app in next-themes' provider:
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} storageKey="libredb-theme">
{children}
</ThemeProvider>Two states only, dark and light — enableSystem is off, so there is no third "system" entry in
the cycle. The storage key is deliberately studio's own rather than next-themes' default
theme: enableSystem={false} does not sanitize a stored "system", it writes it to the
class list verbatim, so a key that a previous system-enabled build could have written is a key
that can hand the document a class="system" and no palette at all.
Anything that renders differently per theme must be guarded against hydration mismatch — the
server has no document to read, so useEffectiveTheme() answers "dark" there and the toggle
renders a neutral label until it has hydrated.
Tailwind CSS v4 introduces CSS-first configuration. The @theme inline directive maps CSS variables to Tailwind utility classes:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
/* ... */
}This enables using semantic class names:
<div className="bg-background text-foreground">
<div className="bg-card border-border">
Content
</div>
</div>Your IDE may show warnings like Unknown at rule @theme. This is expected because:
- Tailwind v4's
@themedirective is new - CSS validators don't recognize it yet
- It works correctly - the build succeeds
To suppress these warnings in VS Code, add to .vscode/settings.json:
{
"css.lint.unknownAtRules": "ignore"
}// Good - uses theme variables
<div className="bg-background text-foreground border-border">
<span className="text-muted-foreground">
<button className="bg-primary text-primary-foreground hover:bg-accent">// Bad - hardcoded colors
<div className="bg-[#050505] text-white border-[#262626]">
<span className="text-zinc-500">
<button className="bg-zinc-900 hover:bg-zinc-800">Use opacity modifiers with theme variables:
<div className="bg-accent/50"> {/* 50% opacity */}
<span className="text-muted-foreground/70"> {/* 70% opacity */}
<div className="border-border/30"> {/* 30% opacity */}Edit src/app/globals.css:
.dark {
/* Change the primary color */
--primary: #3b82f6; /* blue-500 */
--primary-foreground: #ffffff;
/* Change the accent color */
--accent: #1e3a5f;
}Ensure @theme inline maps your variables:
@theme inline {
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-accent: var(--accent);
}Both variable sets are rendered at runtime, so a new colour is only half-added until it has a value in each. Verify with the header toggle, not by reasoning about the values: a token that is legible in dark and 2:1 against a white ground is a token that ships an unreadable light theme.
shadcn/ui buttons use theme variables automatically:
<Button variant="default"> {/* bg-primary */}
<Button variant="secondary"> {/* bg-secondary */}
<Button variant="outline"> {/* border-input */}
<Button variant="ghost"> {/* hover:bg-accent */}
<Button variant="destructive"> {/* bg-destructive */}<Card> {/* bg-card border-border */}
<CardHeader>
<CardTitle> {/* text-card-foreground */}<DropdownMenuContent> {/* bg-popover text-popover-foreground */}<Input> {/* bg-background border-input */}:root {
--warning: #f59e0b;
--warning-foreground: #ffffff;
}
.dark {
--warning: #d97706;
--warning-foreground: #ffffff;
}@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}<div className="bg-warning text-warning-foreground">
Warning message
</div>- Check that the variable is defined in both
:rootand.dark - Verify the
@theme inlinemapping exists - Ensure you're using the correct class name (
bg-cardnotbg-[--card])
- Check the
darkclass on<html>—next-themestoggles it there; if it never changes, the<ThemeProvider>is missing or a stored value is being written verbatim (see Switching Themes) - Ensure the variable is defined in both the
:rootand.darkselectors — a token that exists in one palette only silently resolves to nothing in the other - Confirm
@theme inlinemaps the variable to a--color-*utility.inlineis required: a plain@themeresolves the value at build time and freezes whichever palette was in scope - Embedded in a host app: confirm the host imports
@libredb/studio/styles.css
- Run
bun run buildto check for CSS syntax errors - Verify all variables are properly closed
- Check for typos in variable names
- tweakcn - Interactive shadcn/ui theme editor
- shadcn Theme Generator - Official theme generator
- Tailwind Zinc Palette
- OKLCH Color Space - Modern color space for themes
| Variable | Hex | Description |
|---|---|---|
| background | #ffffff |
White |
| foreground | #0a0a0a |
Near black |
| card | #ffffff |
White |
| primary | #171717 |
Near black |
| secondary | #f5f5f5 |
Light gray |
| muted | #f5f5f5 |
Light gray |
| muted-foreground | #737373 |
Medium gray |
| accent | #f5f5f5 |
Light gray |
| border | #e5e5e5 |
Gray |
| Variable | Hex | Tailwind | Description |
|---|---|---|---|
| background | #09090b |
zinc-950 | Near black |
| foreground | #fafafa |
zinc-50 | Near white |
| card | #0a0a0a |
- | Dark |
| primary | #fafafa |
zinc-50 | Near white |
| secondary | #27272a |
zinc-800 | Dark gray |
| muted | #27272a |
zinc-800 | Dark gray |
| accent | #27272a |
zinc-800 | Dark gray |
| border | #27272a |
zinc-800 | Dark gray |
| muted-foreground | #a1a1aa |
zinc-400 | Medium gray |
Last updated: June 2026