Theming and internationalisation¶
Fresco does not add a theme provider or an i18n context. CSS owns design tokens. Translated strings are ordinary values. The current theme and locale are app-db facts read through ordinary subscriptions.
Put design tokens in CSS¶
Use custom properties for tokens and override them under a theme attribute:
:root {
--app-color-accent: #2b6cb0;
--app-color-surface: #ffffff;
--app-radius: 4px;
}
[data-theme="dark"] {
--app-color-accent: #63b3ed;
--app-color-surface: #1a202c;
}
.app-button {
background: var(--app-color-accent);
border-radius: var(--app-radius);
}
The --app-* prefix is only an example. Use your application's existing token
names. Let :root be the default theme so the page has useful values before
any user preference is loaded.
Changing one data-theme attribute lets the CSS cascade restyle the subtree.
React does not need to re-render each token consumer.
Render the selected theme¶
The user's choice is application state:
(ns app.theme
(:require [re-frame.core :as rf]
[re-frame.fresco :as h]))
(rf/reg-sub :theme/current
(fn [db _query]
(:theme/current db :light)))
(rf/reg-event :theme/choose
(fn [{:keys [db]} [_ theme]]
{:db (assoc db :theme/current theme)}))
(h/defview theme-scope [{:keys [children]}]
(into [:div {:data-theme (name (h/sub [:theme/current]))}]
children))
(h/defview theme-toggle [_]
(let [theme (h/sub [:theme/current])]
[:button
{:on-click [:theme/choose
(if (= theme :dark) :light :dark)]}
(if (= theme :dark) "Light mode" "Dark mode")]))
[theme-scope {}
[app {}]]
children is a realised vector, so into splices it into the wrapper
(Views and reads).
Give the subscription a default. Without (:theme/current db :light), a fresh
app-db returns nil, and (name nil) throws from the root before the page can
render.
Place the scope carefully:
- Theme per frame. Render
data-themeon an element inside the frame. Two frames can then use different themes on one page. - Keep a view boundary immediately underneath. The scope re-renders when
the theme value changes.
[app {}]can then skip its body when its own props and reads are unchanged. An inlined native-tag subtree would re-run with the scope. - Place the scope above foreign crossings. A Client-only host can replace its subtree with a fallback on the server. A theme scope beneath that host would be absent from the server response (SSR and hydration).
Restore a persisted choice through :initial-events so it reaches app-db
before first paint (Installation). The default applies only
when no preference has been chosen.
A theme class works the same way:
Density, brand, and compact-mode settings can use the same pattern.
When the user never overrides the operating-system preference, skip app-db and
use @media (prefers-color-scheme: dark).
Overlays need nothing extra. A dialog in the browser's top layer still inherits
custom properties from its DOM ancestors, and its ::backdrop inherits from the
dialog, so a modal rendered inside theme-scope receives the same tokens
(Overlays and focus).
Treat translated strings as values¶
Store the locale in app-db and derive strings through a subscription:
(def strings
{:en {:greeting "Welcome back"
:todos/empty "Nothing left to do"
:todos/left "left"}
:fr {:greeting "Bon retour"
:todos/empty "Plus rien à faire"
:todos/left "restantes"}})
(rf/reg-sub :i18n/locale
(fn [db _query]
(:i18n/locale db :en)))
(rf/reg-sub :i18n/t
(fn [db [_ k]]
(get-in strings
[(:i18n/locale db :en) k]
(name k))))
(rf/reg-event :i18n/set-locale
(fn [{:keys [db]} [_ locale]]
{:db (assoc db :i18n/locale locale)}))
(h/defview greeting [_]
[:header
[:h1 (h/sub [:i18n/t :greeting])]
[:button {:on-click [:i18n/set-locale :fr]}
"Français"]])
When the locale changes, only views that read translated values need to re-render. The fallback renders the missing key's name, which makes incomplete translation tables visible instead of blank.
The cell below runs the same table and subscriptions. Switch the locale and
the strings change with it. The last line asks for :todos/archived, which
neither table has, so it shows archived.
(require '[re-frame.core :as rf]
'[re-frame.fresco :as h])
(def strings
{:en {:greeting "Welcome back"
:todos/empty "Nothing left to do"
:todos/left "left"}
:fr {:greeting "Bon retour"
:todos/empty "Plus rien à faire"
:todos/left "restantes"}})
(rf/reg-sub :i18n/locale
(fn [db _query]
(:i18n/locale db :en)))
(rf/reg-sub :i18n/t
(fn [db [_ k]]
(get-in strings
[(:i18n/locale db :en) k]
(name k))))
(rf/reg-event :i18n/set-locale
(fn [{:keys [db]} [_ locale]]
{:db (assoc db :i18n/locale locale)}))
(h/defview locale-demo [_]
[:div
[:p [:strong (h/sub [:i18n/t :greeting])]]
[:button {:on-click [:i18n/set-locale :en]} "English"]
" "
[:button {:on-click [:i18n/set-locale :fr]} "Français"]
[:p (h/sub [:i18n/t :todos/empty])]
[:p (h/sub [:i18n/t :todos/archived])]])
[h/frame-root {:id :app}
[locale-demo]]
Format numbers and dates with the platform and the current locale:
(h/defview remaining [_]
(let [locale (name (h/sub [:i18n/locale]))
n (h/sub [:todo/remaining-count])]
[:span.todo-count
(.format (js/Intl.NumberFormat. locale) n)
" "
(h/sub [:i18n/t :todos/left])]))
For separately loaded locale packs, store the loaded table in app-db and let the translation subscription read from it:
A late locale pack is an ordinary app-db update. Views that read its strings update normally.
Avoid storing CSS tokens in app-db¶
;; Don't: every token consumer becomes a subscription and re-render target.
(rf/reg-sub :theme/token
(fn [db [_ k]]
(get-in db [:theme/tokens (:theme/current db) k])))
(h/defview save-button [_]
[:button
{:style {:background (h/sub [:theme/token :accent])
:border-radius (h/sub [:theme/token :radius])}}
"Save"])
The stylesheet already holds these values. Duplicating them in app-db makes a theme switch recompute every token consumer, where CSS custom properties and one attribute do the same job without a re-render.
For the same reason, do not add an i18n provider: the subscription already gives views reactive access to strings.
Vendor theme providers¶
A component library that uses React context still enters through a declared host (Interop):
(ns app.vendor-theme
(:require ["@acme/ui" :refer [ThemeProvider createTheme]]
[re-frame.fresco :as h]))
(def light-theme
(createTheme #js {:mode "light"}))
(def dark-theme
(createTheme #js {:mode "dark"}))
(h/defhost theme-provider ThemeProvider {:server :render})
(h/defview vendor-area [{:keys [children]}]
(into
[theme-provider
{:theme (if (= :dark (h/sub [:theme/current]))
dark-theme
light-theme)}]
children))
Create vendor theme objects once at namespace load and let app-db choose which one to pass. The vendor's context stays on the React side; your own application theme still uses CSS.
Declare the provider {:server :render}: a transparent wrapper left
Client-only drops its whole subtree from the server response
(Interop).
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
First load is blank and the console reports Doesn't support name: |
The theme subscription returned nil, then (name nil) threw |
Give the subscription a default such as (:theme/current db :light) |
| Theme changes in app-db but the page does not change | No rendered element exposes the theme to CSS | Mount theme-scope, or an equivalent attribute carrier, at the frame root |
| A theme switch re-runs the whole application | The content below the scope has no independent view boundary | Put a defview head such as [app {}] immediately below the scope |
| Some strings remain in the old locale | They were computed at load time, stored in a def, or otherwise captured outside render |
Read them where used with (h/sub [:i18n/t k]) |
| A missing translation displays the key name | The translation fallback is working | Add the key to the selected locale table |
| Hydration keeps the server's theme | The hydration payload omitted :theme/current; attribute-only divergence may not produce a useful React warning |
Include the theme choice in the hydration payload (SSR and hydration) |
When not to add this machinery¶
- Use the CSS media query when the application follows the OS and offers no user override.
- Keep literal strings when there is only one real locale. Extract them when a second locale becomes an actual requirement.
- Do not introduce a vendor provider for your own styles. CSS is sufficient.
Advanced¶
Document-level theming¶
An effect can set an attribute on documentElement without a React render:
(rf/reg-fx :page/echo-theme!
{:platforms #{:client}}
(fn [_ctx theme]
(.setAttribute js/document.documentElement
"data-page-theme"
(name theme))))
Use this only for document chrome outside every frame: the body canvas,
scrollbar, or <meta name="theme-color">. Keep a different attribute name so
the rendered frame scope remains the real carrier and the document copy is
clearly cosmetic.
An imperative-only theme does not automatically follow time travel or a restored snapshot. Prefer the rendered attribute unless you have measured a reason not to.