FOUNDATIONS

Theming

Three orthogonal data-attributes on <html> drive every visual choice. Switch one without touching the others, define your own variants on top of the lib's neutral defaults, and persist the user's preference across reloads.

The three switchers

Damo UI reads three independent attributes from the document root. They cascade through every component because the design tokens are CSS variables.

<html> data-attributes
1<html data-theme="light" data-palette="default" data-density="normal">
2  <body>...</body>
3</html>
data-theme
Light / Dark

Toggles surfaces, foreground, and Memphis chrome. The lib ships light onlyyou declare the dark override.

data-palette
Brand variants

No built-in values — declare any list you want (default, sunset, cyberpunk, forest…) and override --primary & friends per palette.

data-density
compact / normal / comfortable

Multiplies vertical spacing via --density-scale-y. Lib ships these three values out of the box.

1. Adding dark mode

Re-declare every public token under [data-theme='dark']. You only need to override the semantic layer — components consume those, never the raw scales directly.

dark mode override
1/* app/globals.css */
2:root[data-theme='dark'] {
3  --background: #09090b;
4  --foreground: #fafafa;
5  --card: #18181b;
6  --card-foreground: #fafafa;
7  --muted: #27272a;
8  --muted-foreground: #a1a1aa;
9
10  --primary: #fafafa;
11  --primary-foreground: #18181b;
12  --secondary: #27272a;
13  --secondary-foreground: #fafafa;
14
15  --border: #27272a;
16  --memphis-shadow-color: #fafafa;
17  --memphis-border-color: #fafafa;
18}
19

The full list of tokens you can override lives in Colors (semantic) and Tokens (architecture).

2. Adding custom palettes

Same pattern, different attribute. You can scope a palette to a single mode by chaining attribute selectors.

palette override + dark combo
1/* app/globals.css — define a custom palette */
2:root[data-palette='cyberpunk'] {
3  --primary: #0f766e;
4  --primary-foreground: #ffffff;
5  --secondary: #7c4dff;
6  --secondary-foreground: #ffffff;
7}
8
9/* Combine palette + dark mode by chaining the selectors */
10:root[data-theme='dark'][data-palette='cyberpunk'] {
11  --background: #170731;
12  --foreground: #14b8a6;
13}
14

3. Density scale

Density flips a single CSS variable that components multiply into their vertical padding. You can change the multipliers globally if the defaults don't suit your product.

density tokens (lib defaults)
1:root { --density-scale-y: 1 }                 /* normal (default) */
2:root[data-density='compact']     { --density-scale-y: 0.75 }
3:root[data-density='comfortable'] { --density-scale-y: 1.25 }
4

4. Drop-in switcher components

The lib ships three controls that read & write the attributes and persist to localStorage. Pop them anywhere — the navbar is the most common home.

components/ThemeBar.tsx
1import { AttrToggleGroup } from 'damo-ui'
2
3export function ThemeBar() {
4  return (
5    <div className="flex gap-3 items-center">
6      <AttrToggleGroup
7        label="Theme"
8        storageKey="theme"
9        attribute="data-theme"
10        options={[
11          { value: 'light', label: 'Light' },
12          { value: 'dark', label: 'Dark' },
13        ]}
14        defaultValue="light"
15      />
16      <AttrToggleGroup
17        label="Palette"
18        variant="select"
19        storageKey="palette"
20        attribute="data-palette"
21        options={[
22          { value: 'default', label: 'Plum + Gold' },
23          { value: 'cyberpunk', label: 'Cyberpunk' },
24        ]}
25        defaultValue="default"
26      />
27      <AttrToggleGroup
28        label="Density"
29        storageKey="density"
30        attribute="data-density"
31        options={[
32          { value: 'compact', label: 'Compatta' },
33          { value: 'normal', label: 'Normale' },
34          { value: 'comfortable', label: 'Ampia' },
35        ]}
36        defaultValue="normal"
37      />
38    </div>
39  )
40}
41

Full props in AttrToggleGroup.

5. Programmatic switching

If the built-in components don't fit (custom UI, settings page, system-preference media query, A/B test…) flip the attributes by hand:

any client component
1// Read & write any of the three attributes from JS:
2const html = document.documentElement
3
4html.dataset.theme = 'dark'         // applies dark vars
5html.dataset.palette = 'cyberpunk'  // applies cyberpunk palette overrides
6html.dataset.density = 'compact'    // applies density scale
7
8// Persist for next visit:
9localStorage.setItem('theme', 'dark')
10localStorage.setItem('palette', 'cyberpunk')
11localStorage.setItem('density', 'compact')
12

6. Preventing the flash

Server-rendered apps render the default theme first, then React hydrates the persisted choice. To avoid the flash of unstyled-theme (FOUT), restore the attributes from localStorage in a blocking script before hydration.

app/layout.tsx
1// app/layout.tsx — prevent flash of incorrect theme
2// App Router note: render a synchronous inline <script> directly in <head>
3// (NOT next/script with beforeInteractive — that strategy is not supported in
4// App Router). The script body must be a hard-coded string literal so React's
5// dangerouslySetInnerHTML never sees user-controlled content.
6
7const PREFERENCES_INIT = `(function(){try{var d=document.documentElement;var t=localStorage.getItem('theme');if(t==='light'||t==='dark')d.setAttribute('data-theme',t);var p=localStorage.getItem('palette');if(p==='default'||p==='sunset'||p==='cyberpunk'||p==='forest')d.setAttribute('data-palette',p);var n=localStorage.getItem('density');if(n==='compact'||n==='normal'||n==='comfortable')d.setAttribute('data-density',n)}catch(e){}})();`
8
9export default function RootLayout({ children }) {
10  return (
11    <html
12      lang="en"
13      data-theme="light"
14      data-palette="default"
15      data-density="normal"
16      suppressHydrationWarning
17    >
18      <head>
19        {/* Synchronous, allow-list validated. Must come before any stylesheet
20            link so first paint sees the persisted preferences. */}
21        <script dangerouslySetInnerHTML={{ __html: PREFERENCES_INIT }} />
22      </head>
23      <body suppressHydrationWarning>{children}</body>
24    </html>
25  )
26}
27
28// Pair this with usePersistedAttr from damo-ui — its lazy-init useState makes
29// React's first commit value match the script's DOM write, so the post-paint
30// effect is a no-op instead of an undo.
31

7. Scoped islands

The attributes work on any element, not only <html>. Wrap a section in data-theme="dark" to flip just that subtree.

scoped theme island
1// Scope a theme to a section instead of the whole page
2<section data-theme="dark" className="bg-background text-foreground">
3  {/* All children render in dark vars regardless of the page-level theme */}
4  <h2>Dark island inside a light page</h2>
5</section>
6
Tip

The Theme Generator authors light + dark variants visually and exports the CSS overrides for steps 1 and 2. Skip the manual hex hunt.

Theming — Damo UI