Skip to main content
Gremorie

Theming

The contract for running Gremorie's six themes and light/dark together — two independent axes, the one switcher setting that breaks it, and the cascade guarantee behind it.

Gremorie theming is two independent axes, and they never share a switch. Get the wiring right once and every component re-skins for free; get one switcher setting wrong and brand and mode fight over the same attribute. This page is the contract.

The two axes

AxisWhat it controlsHow it is setSelector
BrandWhich of the six themes (Default, Claude, …)data-theme on <html>:root[data-theme='claude']
ModeLight or dark within that brandthe dark class on <html>.dark

They compose. Any brand renders in either mode, so all twelve combinations are valid:

<html data-theme="claude">
  <!-- Claude, light -->
  <html data-theme="claude" class="dark">
    <!-- Claude, dark  -->
    <html class="dark">
      <!-- Default, dark -->
      <html>
        <!-- Default, light -->
      </html>
    </html>
  </html>
</html>

Components never know which is active. They ask for bg-primary; the browser resolves var(--primary) in whatever brand-and-mode context the root advertises.

Brand is data-theme, mode is a class — never the reverse

The one mistake that breaks everything is putting mode on data-theme (e.g. data-theme="dark"). That collides with the brand attribute and silently clobbers the active theme. Mode is always the dark class; data-theme is reserved for brand.

Wiring it in your app

next-themes is the standard switcher for React apps, and it is what the Gremorie docs site itself uses through Fumadocs. The critical setting is attribute="class" — this is the default, and it is the one you must not change.

// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class" // ← mode → the `dark` class. Do NOT use attribute="data-theme".
      defaultTheme="system"
      enableSystem
    >
      {children}
    </ThemeProvider>
  );
}

next-themes now owns the mode axis (it toggles class="dark"). Drive the brand axis yourself with a plain attribute set on the document root:

// Anywhere — a brand picker, a layout, a server component:
document.documentElement.dataset.theme = 'claude'; // or remove it for Default

Do not set attribute="data-theme"

Configuring next-themes with attribute="data-theme" makes it write the mode onto data-theme — the exact attribute Gremorie uses for brand. The two overwrite each other. Keep attribute="class" and the two axes stay independent.

No framework, no library. Set the two axes directly on <html>:

const root = document.documentElement;

// Brand
root.dataset.theme = 'gemini'; // or `delete root.dataset.theme` for Default

// Mode
root.classList.toggle('dark', prefersDark);

That is the whole API. A <select> for brand plus a checkbox for mode is a complete theme switcher.

Angular ships no compiled CSS, so you import the tokens theme and toggle the same two attributes on the document root:

import { DOCUMENT } from '@angular/common';
import { inject } from '@angular/core';

export class ThemeService {
  private doc = inject(DOCUMENT);

  setBrand(brand: string | null) {
    if (brand) this.doc.documentElement.dataset['theme'] = brand;
    else delete this.doc.documentElement.dataset['theme'];
  }

  setDark(dark: boolean) {
    this.doc.documentElement.classList.toggle('dark', dark);
  }
}

Why it is safe to compose (the cascade guarantee)

The two axes never collide because they resolve at different specificities, and the stylesheet is emitted in an order that makes the tie-breaks fall the right way. Concretely, for any token:

SelectorSpecificityWins over
:root[data-theme='claude'].dark(0,3,0)everything below
:root[data-theme='claude'](0,2,0):root and .dark
.dark(0,1,0):root (by order)
:root(0,1,0)base

.dark and :root tie at (0,1,0); dark wins because the base .dark block is emitted after :root in the compiled sheet. Each brand's dark override sits higher still, so data-theme="claude" class="dark" lands on the clay-in-the-dark palette with no ambiguity. You do not have to manage any of this — it is a property of how @gremorie/tokens is built — but it is why you can set the two axes independently and trust the result.

Known limitation: theming is document-scoped

The brand selector is anchored to the document root (:root[data-theme]), so exactly one brand is active per page. Switching brand re-themes the entire document — which is exactly what a brand picker wants.

What this does not support today is two brands on screen at once — e.g. a Claude card beside a Gemini card in the same view. That would need the theme scoped to a subtree ([data-theme] on a <div>, the way Radix Themes nests <Theme>), which the current root-anchored selectors do not do. If you need a side-by-side brand comparison, render each brand on its own route or in its own iframe.

See also

On this page