A desktop database client stays open for hours. It is often the first window launched in the morning and the last one closed at night. Under those conditions, the theme is not a cosmetic detail: poor contrast is tiring, a dark mode that flashes at startup is annoying, and an accent color that shifts between the sidebar and the SQL editor betrays a lack of care. So QoreDB treats theming as a real engineering concern.
The result fits in a single hook, src/hooks/useTheme.ts, of just over a hundred lines. No next-themes, no theme-management library. Here is how it works and why it was written this way.
Three preferences, one resolved theme
QoreDB distinguishes two notions. The user's preference, of type ThemePreference, can be 'light', 'dark' or 'auto'. The theme actually applied, of type ResolvedTheme, is only ever 'light' or 'dark'. In 'auto' mode, the resolved theme follows the operating system.
This separation avoids a classic trap. If we stored 'dark' or 'light' directly as the preference, we would lose the user's intent: "follow my system." With 'auto' persisted and the resolved theme computed on the fly, QoreDB can switch automatically when macOS or Windows enters night mode, without the user touching anything.
export type ThemePreference = 'light' | 'dark' | 'auto';
export type ResolvedTheme = 'light' | 'dark';
const resolvedTheme = useMemo<ResolvedTheme>(() => {
return theme === 'auto' ? systemTheme : theme;
}, [theme, systemTheme]);Following the system live
The system theme is read via window.matchMedia('(prefers-color-scheme: dark)'). The hook does not just read this value at startup: it subscribes to its changes. When you switch your OS to dark mode at 6 p.m., the media query's change event updates the systemTheme state, and if your preference is on 'auto', the interface follows immediately.
The detail that matters: the code handles both matchMedia APIs. The modern one, addEventListener('change', …), and the older one, addListener(…), for webviews based on Safari versions earlier than 14. Since QoreDB runs on Tauri 2 with the system's native webview, this compatibility is not theoretical: it depends on the version of the WebKit engine installed on the machine.
One attribute and one class: why both
This is the hook's most interesting choice. When the resolved theme changes, QoreDB writes two things onto the document's root element.
useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', resolvedTheme);
if (resolvedTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem(STORAGE_KEY, theme);
}, [resolvedTheme, theme]);The data-theme attribute drives QoreDB's own design tokens. In src/index.css, the variables are defined under :root for light and redefined under :root[data-theme='dark'] for dark. The .dark class, in turn, activates Tailwind's dark variant and a second set of variables. QoreDB is on Tailwind v4, where the dark variant is declared explicitly at the top of the stylesheet.
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));Writing both in a single gesture guarantees that no style layer can end up out of sync. Components that use a Tailwind utility class like dark:bg-… and those that read a --q-* variable directly switch at the same instant, on the same signal.
A single source of truth: CSS variables
The entire palette goes through named CSS variables. The neutrals run from --q-bg-0 to --q-bg-2 for backgrounds, --q-text-0 to --q-text-2 for text, with an accent color --q-accent and semantic tokens --q-success, --q-warning, --q-error. Going from light to dark changes not a single variable name in the components: only the value behind it changes.
That is what "Tailwind consistency" means here. Rather than scattering #0b0c0f across classes, we define the color once and reference it everywhere. The sidebar accent, an environment badge color and a card background all come from the same set of tokens. A palette change happens in a single place.
Persistence and cross-window sync
The preference is written to localStorage under the key qoredb-theme. On the next launch, the hook reads it back as soon as it initializes its state, which avoids the flash of a default theme before the right one is applied.
But QoreDB can have several windows open. For a change made in one window's settings to propagate to the others, setTheme does two things: it persists the value, and it emits a custom event qoredb:theme-changed. Each instance of the hook listens both to that event and to the native storage event, fired when another context modifies localStorage.
const setTheme = useCallback((next) => {
setThemeState(prev => {
const computed = typeof next === 'function' ? next(prev) : next;
if (computed === prev) return prev;
localStorage.setItem(STORAGE_KEY, computed);
window.dispatchEvent(
new CustomEvent(THEME_EVENT, { detail: { theme: computed } })
);
return computed;
});
}, []);Both paths converge on the same guard: the update is applied only if the new value differs from the previous one, which avoids needless renders and event loops between windows.
The SQL editor follows the same theme
A code editor that stays bright white inside a dark application is the most visible failure of a theming system. QoreDB's SQL editor, built on CodeMirror 6, consumes the hook like any other component: it reads isDark, a convenience exposed by useTheme and equal to resolvedTheme === 'dark'.
In dark mode, the editor adds the oneDark extension, then a CodeMirror theme applied last that overrides oneDark's background with the same variable as the rest of the interface, var(--q-bg-1). The editor is not merely dark: it is dark in the exact same shade as the window around it.
if (isDark) {
extensions.push(oneDark);
}
// Custom theme applied last so it overrides oneDark background
extensions.push(
EditorView.theme({
'&': {
height: '100%',
...(isDark ? { backgroundColor: 'var(--q-bg-1)' } : {}),
},
// ...
})
);The full set of extensions is recomputed via a useMemo whose dependencies include isDark. Toggling the theme therefore rebuilds the editor configuration with the right set of styles, without recreating the instance by hand.
The selector, in settings
In the preferences, the general section exposes a three-entry menu, all coming from the same hook:
- System:
setTheme('auto'), and the interface shows the actually resolved theme in parentheses. - Light:
setTheme('light'), forced regardless of the system preference. - Dark:
setTheme('dark'), forced as well.
A deliberate stance: little code, controlled end to end
We could have pulled in a theme-management library. We did not, because the need was simple and fully owning it was worth more than adding a dependency. A hundred readable lines, a three-value preference, two DOM signals set together, one set of CSS variables shared by Tailwind and by CodeMirror: that is enough for the theme to be instant at startup, to follow the system, to sync across windows and to stay consistent everywhere, right down to the background of the SQL editor.
It is the same logic as in the rest of QoreDB: prefer code you understand and that does exactly what is needed, over the abstraction that does too much and slips out of your control.
Stay updated on new releases
Subscribe to get product releases, new drivers notifications, and technical tutorials.

