17 KiB
Imported from docs/integration.md in the go-bananas repository.
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
- The thirty-second version
- How theming works
- Choosing an integration
- Any HTML page
- Plain JavaScript
- React
- Server-rendered frameworks
- Tailwind
- Reading tokens in JavaScript
- Email and documents
- Runtime switching
- Versioning and caching
- Offline and failure behaviour
- 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.
Recommended policy
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-appearanceon 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, orapplyThemecalled 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, ordata-bananas-appearanceon<html> - A fallback, so a theme that fails to load leaves the page usable
X-Theme-Versionlogged with your request id- For a multi-theme page, one stylesheet per distinct token set, correctly scoped