Skip to content

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-theme on 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:

{:class (str "app app--" (name theme))}

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:

(get-in db [:i18n/strings locale k])

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.