# 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](#the-thirty-second-version) 2. [How theming works](#how-theming-works) 3. [Choosing an integration](#choosing-an-integration) 4. [Any HTML page](#1-any-html-page) 5. [Plain JavaScript](#2-plain-javascript) 6. [React](#3-react) 7. [Server-rendered frameworks](#4-server-rendered-frameworks) 8. [Tailwind](#5-tailwind) 9. [Reading tokens in JavaScript](#reading-tokens-in-javascript) 10. [Email and documents](#email-and-documents) 11. [Runtime switching](#runtime-switching) 12. [Versioning and caching](#versioning-and-caching) 13. [Offline and failure behaviour](#offline-and-failure-behaviour) 14. [Multiple themes on one page](#multiple-themes-on-one-page) --- ## The thirty-second version ```html ``` 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. ```css [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 `` 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](#1-any-html-page) | | A SPA you control | [Plain JavaScript](#2-plain-javascript) | | React | [React](#3-react) | | Django, Rails, Laravel, Go templates, Jinja | [Server-rendered](#4-server-rendered-frameworks) | | Tailwind | [Tailwind](#5-tailwind) | | Canvas, charts, PDFs, email | [Reading tokens](#reading-tokens-in-javascript) | --- ## 1. Any HTML page The stylesheet link. Two tags if you want the theme to follow the operating system. ```html ``` `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=`, including a live preview of what it looks like. --- ## 2. Plain JavaScript An ES module loaded by URL. ```html ``` Or with a `package.json`-free bundler, by aliasing the URL: ```js // 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: ```html ``` --- ## 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. ```html ``` Or in a bundled application, alias the URL to a bare specifier: ```js // vite.config.js resolve: { alias: { 'go-bananas/runtime': 'https://themes.example.com/rt/v1/react.js' } } ``` ```jsx 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 ```jsx 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 return ( <> ) } 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 } ``` `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 `` 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. ```jinja {# Django #} ``` ```erb <%# Rails ERB %> ``` ```go {{/* Go html/template */}} ``` 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 ```go func themeStylesheet(base, slug string) template.HTML { return template.HTML(fmt.Sprintf( ``, 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. ```css /* 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. ```bash 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](https://www.designtokens.org/) 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. ```js 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 ```js 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. ```go 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