go-bananas/docs/integration.md
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

17 KiB

Integrating a go-bananas theme

Everything here works without installing anything. A theme is data delivered over HTTP, so the integration is a URL, not a dependency.


Contents

  1. The thirty-second version
  2. How theming works
  3. Choosing an integration
  4. Any HTML page
  5. Plain JavaScript
  6. React
  7. Server-rendered frameworks
  8. Tailwind
  9. Reading tokens in JavaScript
  10. Email and documents
  11. Runtime switching
  12. Versioning and caching
  13. Offline and failure behaviour
  14. Multiple themes on one page

The thirty-second version

<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">

Done. Every --bn-* variable is now defined, and the bundled bn-* component classes are available.


How theming works

A theme renders to a stylesheet of CSS custom properties, all prefixed --bn-, plus a small component layer.

[data-bananas-theme="midnight"] {
  --bn-color-semantic-background: #1a1d23;
  --bn-color-semantic-text: #d9dbe5;
  --bn-color-semantic-primary: #7c6bf5;
  --bn-space-4: 1rem;
  --bn-radius-lg: 0.75rem;
}

[data-bananas-theme="midnight"][data-bananas-appearance="dark"] { /* … */ }

.dark [data-bananas-theme="midnight"],
[data-bananas-theme="midnight"].dark { /* … */ }

Two attributes do all the work:

Attribute Values Effect
data-bananas-theme a theme slug Activates that theme on the element and its subtree
data-bananas-appearance light, dark Selects the colour scheme

Put data-bananas-theme on <html> for the whole page, or on any element for a subtree. Several themes can coexist.

The stylesheet also honours a .dark ancestor class and prefers-color-scheme, so it fits an application that already toggles dark mode the Tailwind or shadcn way.

The component layer

The generated stylesheet includes a small set of classes written entirely in terms of the variables: .bn-btn, .bn-card, .bn-input, .bn-badge, .bn-alert, .bn-table, .bn-link.

Use them to look correct immediately, then replace them with your own components as soon as you want them. They exist so that the first integration needs no CSS work at all.

They are placed in @layer bananas, so your own unlayered CSS always wins the cascade. You will never need !important to override them.

Request them with ?variant=tokens if you want the variables without the component layer.


Choosing an integration

Your stack Use
Server-rendered HTML, any framework Any HTML page
A SPA you control Plain JavaScript
React React
Django, Rails, Laravel, Go templates, Jinja Server-rendered
Tailwind Tailwind
Canvas, charts, PDFs, email Reading tokens

1. Any HTML page

The stylesheet link. Two tags if you want the theme to follow the operating system.

<link rel="stylesheet" href="https://themes.example.com/rt/v1/theme.css?slug=midnight">
<script async src="https://themes.example.com/rt/v1/embed.js"
        data-theme="midnight"
        data-appearance="system"></script>

embed.js is a single file with no dependencies. It sets the attributes and fetches the stylesheet before first paint.

Configure it with data attributes:

Attribute Values Default
data-theme a theme slug default
data-appearance light, dark, system system
data-variant full, tokens, minified full
data-storage false to skip localStorage persists
data-auto-dark true to follow the system follows data-appearance

A generated snippet for any theme is at /rt/v1/embed?slug=<slug>, including a live preview of what it looks like.


2. Plain JavaScript

An ES module loaded by URL.

<script type="module">
  import { applyTheme, watchSystemAppearance } from 'https://themes.example.com/rt/v1/runtime.js'

  watchSystemAppearance()      // follow the operating system preference
  await applyTheme('midnight') // fetch and apply
</script>

Or with a package.json-free bundler, by aliasing the URL:

// vite.config.js, webpack, esbuild — the tool does not care which.
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/runtime.js' } }

The module API:

Export Purpose
applyTheme(slug, options?) Fetch and apply. Returns the resolved payload.
setAppearance(appearance) Change the scheme without re-fetching.
getAppearance(), getSlug() Read the current state.
clearTheme() Remove the theme, restoring the page's own styles.
loadManifest() Fetch the available themes.
loadTheme(slug, appearance?) Fetch one theme's resolved tokens.
getVariables(name?) Read a token by name, or all of them.
watchSystemAppearance(callback?) Follow the operating system. Returns an unsubscribe.
subscribe(listener) Observe theme changes. Returns an unsubscribe.
assetUrl(path, query?) Build a runtime asset URL, for your own fetches.

There is also a global build that attaches the same API to window.Bananas, for a page that cannot use modules:

<script src="https://themes.example.com/rt/v1/global.js"></script>
<script>Bananas.applyTheme('midnight')</script>

3. React

A provider and hooks, loaded through an import map. React stays your own copy. The module imports the bare specifier react, which the import map resolves to the application's own React — two copies of React in one page break hooks, because each copy has its own dispatcher state.

<script type="module">
  import { BananasProvider } from 'https://themes.example.com/rt/v1/react.js'
  import { createRoot } from 'https://esm.sh/react-dom@19/client'

  createRoot(document.getElementById('root')).render(
    <BananasProvider slug="midnight" autoDark>
      <App />
    </BananasProvider>,
  )
</script>

Or in a bundled application, alias the URL to a bare specifier:

// vite.config.js
resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/react.js' } }
import { BananasProvider, useBananas, useThemeVariable } from 'go-bananas/runtime'

Props

Prop Default Purpose
slug — The theme to apply
appearance system light, dark or system
autoDark false Follow the operating system
baseUrl inferred Runtime base, for a cross-origin deployment
storage true Persist the user's choice
variant full Render variant
loadManifest false Fetch the theme list on mount
onError — Called when loading fails

Hooks

import { useBananas, useThemeVariable, useThemeVariables, useAvailableThemes } from 'go-bananas/runtime'

function ThemeSwitcher() {
  const { slug, appearance, setTheme, setAppearance, status } = useBananas()
  const { themes } = useAvailableThemes()

  if (status === 'loading') return <Spinner />

  return (
    <>
      <select value={slug ?? ''} onChange={(event) => setTheme(event.target.value)}>
        {themes.map((theme) => (
          <option key={theme.slug} value={theme.slug}>{theme.name}</option>
        ))}
      </select>

      <button onClick={() => setAppearance(appearance === 'dark' ? 'light' : 'dark')}>
        {appearance === 'dark' ? 'Light' : 'Dark'}
      </button>
    </>
  )
}

function ThemedButton() {
  // Returns the resolved CSS value, so the component works in any scheme.
  const background = useThemeVariable('color-semantic-primary')
  const foreground = useThemeVariable('on-primary')
  return <button style={{ background, color: foreground }}>Save</button>
}

useBananas() throws when used outside a provider, rather than returning a default. A silent default would let a component render with the wrong theme and never report the mistake.

Cross-application consistency

useSyncExternalStore is used under the provider, so a theme change made outside React — a switcher in another part of the page, a browser navigation, a postMessage from an iframe — repaints the tree rather than tearing it.


4. Server-rendered frameworks

The stylesheet link is a <link> tag. It works identically in Django, Rails, Laravel, Go's html/template, Jinja, ERB and anywhere else, because the mechanism is the browser's, not a framework's.

{# Django #}
<link rel="stylesheet" href="{{ theme_service_url }}/rt/v1/theme.css?slug={{ theme_slug }}">
<%# Rails ERB %>
<link rel="stylesheet" href="<%= themes_url %>/rt/v1/theme.css?slug=<%= @theme_slug %>">
{{/* Go html/template */}}
<link rel="stylesheet" href="{{ .ThemeBase }}/rt/v1/theme.css?slug={{ .ThemeSlug }}">

For a per-request theme, resolve the slug in your handler and template it in. The stylesheet is immutable by URL and safe to cache at any layer, so rendering the link per request costs nothing.

Go

func themeStylesheet(base, slug string) template.HTML {
    return template.HTML(fmt.Sprintf(
        `<link rel="stylesheet" href="%s/rt/v1/theme.css?slug=%s">`, base, url.PathEscape(slug)))
}

5. Tailwind

Two options, depending on whether the theme must be live.

Live: consume the variables

Works with any Tailwind version, needs no rebuild, and a theme publish applies immediately.

/* app.css */
@import 'tailwindcss';

@theme inline {
  --color-primary: var(--bn-color-semantic-primary);
  --color-background: var(--bn-color-semantic-background);
  --color-foreground: var(--bn-color-semantic-text);
  --color-muted: var(--bn-color-semantic-text-muted);
  --color-border: var(--bn-color-semantic-border);
  --color-danger: var(--bn-color-semantic-danger);
  --radius-card: var(--bn-radius-lg);
}

Then use the semantic names normally: bg-background text-foreground bg-primary text-primary rounded-card.

Build-time: generate a preset

For a theme that changes rarely and should be baked into the CSS.

curl -s "$THEMES/rt/v1/theme.json?slug=acme" > tokens.json
npx @tokens-studio/sd-transform-cli tokens.json tokens.json
npx tailwindcss --theme ./tokens.json -i app.css -o out.css

The JSON is a standard DTCG document, so it feeds any token pipeline rather than only this one.


Reading tokens in JavaScript

Some things cannot read CSS: a canvas chart, a PDF generator, an email template, a colour picker that needs to show a palette.

const { variables, tokens, theme } = await fetch(
  `${THEMES}/rt/v1/theme.json?slug=midnight`,
).then((response) => response.json())

theme.slug          // 'midnight'
theme.version       // 3
variables['--bn-color-semantic-primary']   // '#7c6bf5'
variables['--bn-radius-lg']               // '0.75rem'

The payload has three parts:

Field Shape Use
theme identity Which theme and version this is
variables flat map CSS custom property name to value
tokens nested DTCG The tree, for mapping onto component props

The same is available from the runtime module as getVariables() and loadTheme(), with caching.

Chart example

const { variables } = await loadTheme('midnight')

new Chart(canvas, {
  data,
  options: {
    plugins: {
      legend: { labels: { color: variables['--bn-color-semantic-text-muted'] } },
      scales: {
        x: { grid: { color: variables['--bn-color-semantic-border'] } },
        y: { grid: { color: variables['--bn-color-semantic-border'] } },
      },
    },
  },
})

Email and documents

Email clients have no CSS custom property support. Resolve the variables server-side and inline the values.

func themeForEmail(base, slug string) map[string]string {
    resp, err := http.Get(base + "/rt/v1/theme.json?slug=" + url.QueryEscape(slug))
    if err != nil {
        return nil
    }
    defer resp.Body.Close()

    var payload struct {
        Variables map[string]string `json:"variables"`
    }
    if json.NewDecoder(resp.Body).Decode(&payload) != nil {
        return nil
    }
    return payload.Variables
}

// Inline the values; a <style> block is stripped by most clients.
html := fmt.Sprintf(
    `<td style="background:%s;color:%s;font-family:%s">…</td>`,
    v["--bn-color-semantic-surface-raised"],
    v["--bn-color-semantic-text"],
    v["--bn-font-family-sans"],
)

Resolve once per message and cache it. A theme changes rarely, so a five-minute cache is plenty.


Runtime switching

// A theme picker.
import { applyTheme, loadManifest } from '.../rt/v1/runtime.js'

const { themes } = await loadManifest()
for (const theme of themes) {
  button.onclick = () => applyTheme(theme.slug)
}

Or with no JavaScript at all:

<select onchange="document.documentElement.dataset.bananasTheme = this.value">
  <option value="midnight">Midnight</option>
  <option value="daylight">Daylight</option>
</select>

The theme picker must be outside the themed subtree, or it restyles itself on every change. Put it in a fixed header above the themed container.


Versioning and caching

Runtime responses are immutable by URL.

# The latest version. This URL changes when the theme is republished.
/rt/v1/theme.css?slug=midnight

# A specific version. This URL never changes, ever.
/rt/v1/theme.css?slug=midnight&v=7

Pinning v is what makes a safe deploy possible: build against version 7, and the render cannot change underneath you. The /rt/v1/themes/{slug} path form resolves to the latest version.

Header Use
ETag Revalidation. Answer a 304 and transfer no body.
Cache-Control max-age=60, s-maxage=300, stale-while-revalidate=86400
X-Theme-Version Which version was served. Log it.
X-Theme-Updated-At When it was published.
sourceURL comment in the CSS Navigate from browser devtools to the endpoint

Log X-Theme-Version alongside your request id. "The theme looks wrong" becomes answerable: you know which version that request resolved.

Consumers: CDN with s-maxage in the hundreds and a long stale-while-revalidate. Revalidation is a few milliseconds and no body.

Browsers: leave the Cache-Control as served. The version-scoped URL means the cache entry is never stale.

Rollback: publishing is a new version, and the latest-version URL follows it. A rollback propagates within one s-maxage window.


Offline and failure behaviour

A theme that fails to load leaves the page as it was, with the application's own styles. It does not break the page.

That is deliberate. A theming service is a presentation concern; it should never be able to take an application down. Every entry point catches its own failure:

applyTheme('midnight').catch((error) => {
  reportError(error)   // the page is still usable
})

If you need a theme to be non-optional, add a <style> block in your own CSS with your brand values as the fallback. The custom properties will be overridden when the theme arrives, and used when it does not.


Multiple themes on one page

Theme attributes scope to a subtree, so a documentation site with per-product themes is one attribute per section.

<body>
  <header data-bananas-theme="midnight">
    <!-- always the dark theme -->
  </header>

  <main>
    <section data-bananas-theme="daylight">
      <!-- light theme, even though the page default is dark -->
    </section>
    <section data-bananas-theme="high-contrast">
      <!-- maximum contrast -->
    </section>
  </main>
</body>

Two rules worth knowing:

  • Only one theme stylesheet is loaded at a time. Scoping means several themes on one page share the variables in the most recently applied stylesheet. Themes whose token sets differ will collide.
  • Appearance is per subtree. data-bananas-appearance on a section overrides the document's scheme for that section only.
<section data-bananas-theme="daylight" data-bananas-appearance="dark">
  <!-- the daylight theme, in its dark scheme -->
</section>

If you need genuinely different variable sets on one page, request each theme's stylesheet as a separate <link> and scope each to its own subtree. That works because the selectors are attribute-scoped; the later link does not overwrite the earlier one.


Checklist

  • A <link> to the theme stylesheet, or applyTheme called before first paint
  • The theme attribute set on <html>, for a page-wide theme
  • Components consuming semantic tokens (color.semantic.primary), not palette steps
  • Dark mode handled: autoDark, or data-bananas-appearance on <html>
  • A fallback, so a theme that fails to load leaves the page usable
  • X-Theme-Version logged with your request id
  • For a multi-theme page, one stylesheet per distinct token set, correctly scoped