DuckVizBeta
Guides

Theming Customization

Build custom themes, persist them per user, run an in-app theme drawer, and export a theme as a copy-paste snippet.

After this guide you can take a <DuckvizThemeProvider> further: persist the active theme per user, let users build their own themes inside your app with a live preview, and emit the result as a copy-pasteable code snippet your customers can drop into their own consumer app. This is the same flow we run inside app.duckviz.com.

If you only need preset switching, Custom Themes is enough — start there. Come back here when you want a theme drawer, persistence, or export.

What you're going to assemble

useThemeStore          // Zustand + localStorage — presetId, mode, customPresets[]

<ThemeShell>           // Resolves the active preset, wraps children
  ├─ <DuckvizThemeProvider>   // Sets --app-* CSS vars on <html>
  └─ children

       <ThemeDrawer>   // UI for switching presets / building custom ones

        <CustomThemeEditor>  // Live-preview seed picker
        <ThemeCodeSnippet>   // Copy-paste export

Three responsibilities, kept apart on purpose:

  1. State — what theme the user picked, in localStorage
  2. Application — putting CSS vars on <html> so every component sees them
  3. UX — the drawer / editor / snippet that lets the user change state

1. Theme state with persistence

Hold the active theme in a Zustand store. The shape mirrors what the host app uses:

// stores/theme.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import type { DuckvizThemePreset } from "@duckviz/ui";

export type ThemeMode = "light" | "dark" | "auto";

interface ThemeState {
  presetId: string;
  mode: ThemeMode;
  customPresets: DuckvizThemePreset[];
}

interface ThemeActions {
  setPresetId: (id: string) => void;
  setMode: (mode: ThemeMode) => void;
  addCustomPreset: (preset: DuckvizThemePreset) => void;
  removeCustomPreset: (id: string) => void;
}

export const useThemeStore = create<ThemeState & ThemeActions>()(
  persist(
    (set) => ({
      presetId: "terracotta",
      mode: "auto",
      customPresets: [],
      setPresetId: (presetId) => set({ presetId }),
      setMode: (mode) => set({ mode }),
      addCustomPreset: (preset) =>
        set((s) => ({ customPresets: [...s.customPresets, preset] })),
      removeCustomPreset: (id) =>
        set((s) => ({
          customPresets: s.customPresets.filter((p) => p.id !== id),
          presetId: s.presetId === id ? "terracotta" : s.presetId,
        })),
    }),
    { name: "your-app-theme", storage: createJSONStorage(() => localStorage) },
  ),
);

Server-side sync (Supabase, your DB, anything) is optional and lives in a separate hook. Pull on login → patch the store; debounce 500 ms on changes → upsert. Don't put fetch logic inside the store itself — it complicates SSR and testing.

2. The ThemeShell wrapper

ThemeShell reads the store, resolves the active preset (built-in or custom), and renders <DuckvizThemeProvider>:

// components/theme-shell.tsx
"use client";
import { useMemo } from "react";
import { DuckvizThemeProvider, ALL_PRESETS } from "@duckviz/ui";
import { useThemeStore } from "../stores/theme";

export function ThemeShell({ children }: { children: React.ReactNode }) {
  const presetId = useThemeStore((s) => s.presetId);
  const mode = useThemeStore((s) => s.mode);
  const customPresets = useThemeStore((s) => s.customPresets);

  const preset = useMemo(
    () =>
      ALL_PRESETS.find((p) => p.id === presetId) ??
      customPresets.find((p) => p.id === presetId) ??
      ALL_PRESETS[0]!,
    [presetId, customPresets],
  );

  return (
    <DuckvizThemeProvider preset={preset} mode={mode}>
      {children}
    </DuckvizThemeProvider>
  );
}

Wrap your app once, near the root:

// app/layout.tsx
<body>
  <ThemeShell>{children}</ThemeShell>
</body>

If you're also using Mantine

The DuckViz app's ThemeShell additionally wraps <MantineProvider> and remaps the brand color tuple from the active preset's --app-primary-default. If you use Mantine, do the same — otherwise non-DuckViz components still render with Mantine's defaults.

3. The theme drawer

A drawer is the simplest UX: a right-side panel listing every preset (built-in + custom) with a small color preview, plus a tab for building a new custom theme.

// components/theme-drawer.tsx
"use client";
import { Drawer, Tabs } from "@duckviz/ui";
import { ALL_PRESETS } from "@duckviz/ui";
import { useThemeStore } from "../stores/theme";
import { CustomThemeEditor } from "./custom-theme-editor";

export function ThemeDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
  const presetId = useThemeStore((s) => s.presetId);
  const setPresetId = useThemeStore((s) => s.setPresetId);
  const customPresets = useThemeStore((s) => s.customPresets);

  return (
    <Drawer opened={opened} onClose={onClose} title="Theme" position="right">
      <Tabs defaultValue="presets">
        <Tabs.List>
          <Tabs.Tab value="presets">Presets</Tabs.Tab>
          <Tabs.Tab value="custom">Build your own</Tabs.Tab>
        </Tabs.List>

        <Tabs.Panel value="presets">
          {[...ALL_PRESETS, ...customPresets].map((p) => (
            <button
              key={p.id}
              onClick={() => setPresetId(p.id)}
              data-active={p.id === presetId}
            >
              {p.name}
            </button>
          ))}
        </Tabs.Panel>

        <Tabs.Panel value="custom">
          <CustomThemeEditor />
        </Tabs.Panel>
      </Tabs>
    </Drawer>
  );
}

<Drawer>, <Tabs>, <Button> — every primitive comes from @duckviz/ui and reads the same --app-* tokens, so the drawer itself rethemes live as the user picks presets.

4. The custom theme editor

Eight color inputs feeding buildThemeTokens(). Bind each input to local state, debounce a few ms, and write to a "draft preset" you also pass to <DuckvizThemeProvider> for the preview.

// components/custom-theme-editor.tsx
"use client";
import { useState, useMemo } from "react";
import {
  buildThemeTokens,
  DuckvizThemeProvider,
  type ThemeSeeds,
} from "@duckviz/ui";
import { useThemeStore } from "../stores/theme";

const INITIAL: ThemeSeeds = {
  primary: "#2563eb",
  background: "#ffffff",
  surface: "#f8fafc",
  text: "#0f172a",
  border: "#e2e8f0",
  success: "#16a34a",
  danger: "#dc2626",
  warning: "#d97706",
};

export function CustomThemeEditor() {
  const [seeds, setSeeds] = useState<ThemeSeeds>(INITIAL);
  const [name, setName] = useState("My theme");
  const addCustomPreset = useThemeStore((s) => s.addCustomPreset);
  const setPresetId = useThemeStore((s) => s.setPresetId);

  const draft = useMemo(
    () => buildThemeTokens(`custom-${slugify(name)}`, name, seeds),
    [name, seeds],
  );

  const save = () => {
    addCustomPreset(draft);
    setPresetId(draft.id);
  };

  return (
    <DuckvizThemeProvider preset={draft} mode="light">
      {/* Eight color inputs writing into seeds */}
      <ColorInput label="Primary" value={seeds.primary}
        onChange={(v) => setSeeds((s) => ({ ...s, primary: v }))} />
      {/* …repeat for the seven other seeds… */}

      {/* Live preview — every child re-themes as seeds change */}
      <PreviewCard />

      <button onClick={save}>Save theme</button>
    </DuckvizThemeProvider>
  );
}

A nested <DuckvizThemeProvider> scopes the draft tokens to the editor — the rest of your app keeps the previously-saved theme until Save is clicked. That's important: users want to experiment without their whole sidebar flickering.

5. Exporting a custom theme as a snippet

When the user wants to ship their theme into a downstream consumer app, emit a copy-paste snippet. Two forms — built-in preset vs custom theme:

Built-in preset:

import { DuckvizThemeProvider, ALL_PRESETS } from "@duckviz/ui";

const theme = ALL_PRESETS.find((p) => p.id === "ocean")!;

export default function App({ children }) {
  return (
    <DuckvizThemeProvider preset={theme} mode="auto">
      {children}
    </DuckvizThemeProvider>
  );
}

Custom theme:

import { DuckvizThemeProvider, buildThemeTokens } from "@duckviz/ui";

const theme = buildThemeTokens("my-theme", "My theme", {
  primary: "#2563eb",
  background: "#ffffff",
  surface: "#f8fafc",
  text: "#0f172a",
  border: "#e2e8f0",
  success: "#16a34a",
  danger: "#dc2626",
  warning: "#d97706",
});

export default function App({ children }) {
  return (
    <DuckvizThemeProvider preset={theme} mode="auto">
      {children}
    </DuckvizThemeProvider>
  );
}

Generate this string from the active preset's seeds and offer a single Copy button. The DuckViz app's theme-code-snippet.tsx does exactly this — feel free to mirror its tokenizer if you want syntax-highlighted output.

6. Reading your theme from custom code

Components from @duckviz/widgets, @duckviz/dashboard, and @duckviz/explorer all read CSS vars at runtime via getComputedStyle. Your own components should do the same:

function readToken(name: string): string {
  if (typeof window === "undefined") return ""; // SSR safety
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}

const primary = readToken("--app-primary-default");

D3 charts: never pass var() into D3 color functions

D3's interpolateRgb, scaleSequential, etc. cannot parse var(--app-primary-default). Resolve the var to a hex string with getComputedStyle first. Hard-coded hex fallbacks are last-resort only — they break theme switching.

7. Third-party CSS

@duckviz/dashboard bundles react-grid-layout and react-resizable CSS into its own dist/index.css. @duckviz/explorer does the same for react-resizable-panels. Do not import the third-party stylesheets separately — that loads them twice and you can end up with conflicting rules. The package CSS is the single source of truth.

See also

  • Custom Themes — preset list + buildThemeTokens reference
  • @duckviz/uiDuckvizThemeProvider, useColorScheme, primitives
  • PerformancedefaultRowCap, largeFileWarnMB, render budgets