Design token system for Mob apps.
A theme is a compiled %Mob.Theme{} struct — a flat map of semantic tokens
for colors, spacing, radii, and scale factors. The renderer resolves these
tokens at render time so every component picks up the active theme
automatically.
Using a named theme
Named themes are plain modules that export theme/0. Pass the module to
use Mob.App:
use Mob.App, theme: MobThemes.Obsidian # (the mob_themes style package)Override individual tokens without leaving the theme:
use Mob.App, theme: {MobThemes.Obsidian, primary: :rose_500}Anyone can publish a theme as a Hex package — any module with theme/0
returning a Mob.Theme.t() works:
use Mob.App, theme: AcmeCorp.BrandThemeBuilding a theme from scratch
Pass a keyword list of overrides against the neutral base:
use Mob.App, theme: [primary: :emerald_500, type_scale: 1.1]Or change the theme at runtime (e.g. for accessibility or user preference):
Mob.Theme.set(MobThemes.Obsidian)
Mob.Theme.set({MobThemes.Obsidian, type_scale: 1.2})
Mob.Theme.set({MobThemes.Obsidian, text_scale: 1.3}) # the user's text size
Mob.Theme.set(primary: :pink_500)Base theme
When no theme is set the renderer uses the neutral base — plain dark grays with a standard blue primary. Functional, not opinionated. Good enough for hello world; swap in a named theme when you want personality.
Token reference
Semantic color tokens
:primary — main action colour (default :blue_500)
:on_primary — text/icons on primary (default :white)
:secondary — secondary action colour (default :gray_600)
:on_secondary — text/icons on secondary (default :white)
:background — page/screen background (default :gray_900)
:on_background — text on background (default :gray_100)
:surface — card / sheet background (default :gray_800)
:surface_raised — elevated card background (default :gray_700)
:on_surface — text/icons on surface (default :gray_100)
:muted — secondary/placeholder text (default :gray_500)
:error — error state colour (default :red_500)
:on_error — text/icons on error (default :white)
:border — dividers and outlines (default :gray_700)Spacing tokens (scaled by space_scale)
:space_xs → 4 × scale
:space_sm → 8 × scale
:space_md → 16 × scale
:space_lg → 24 × scale
:space_xl → 32 × scaleRadius tokens
:radius_sm → theme.radius_sm (default 6)
:radius_md → theme.radius_md (default 10)
:radius_lg → theme.radius_lg (default 16)
:radius_pill → theme.radius_pill (default 100)Scale factors
type_scale: 1.0 # multiply the text size tokens (:sm, :base, :xl…) by this
text_scale: 1.0 # multiply every text_size / font_size prop, number or token
space_scale: 1.0 # multiply all spacing tokens by thistype_scale belongs to the theme's design: it scales the token sizes and
leaves a numeric text_size (a size pinned to a design) alone.
text_scale is the user's text-size preference (a Settings choice such as
Small … Largest): the renderer multiplies it into every node's text_size
and font_size prop, numeric or token, so an app written in exact numbers
still grows. Only sizes on the node scale: a :text with no text_size
keeps the platform default (give it :base to follow), and a sized
:icon's glyph grows with it. Not scaled: a Canvas text op's size
(it's in the drawing's coordinates) and sizes nested in a plugin's own
props (a style map). It multiplies the OS font scale where native follows
one: Android text sizes are sp; on iOS, custom-font text (Font.custom(_:size:))
follows Dynamic Type, while system-font text and the UIKit-backed text
field are fixed points. At 1.0 (the default) every value reaches native
exactly as written.
Set it with the rest of the theme, and keep it when you switch themes:
Mob.Theme.set({MyApp.Theme.Dark, text_scale: 1.3})Font tokens
fonts is a name → value map, resolved by the renderer exactly like
colors (font: :heading walks this map, same two-step shape as
text_color: :primary). :default is special — set it and the renderer
injects it automatically onto any node that doesn't specify its own
font:, so the whole app picks up a custom font without repeating it
everywhere:
Mob.Theme.set(
fonts: %{
default: Mob.Theme.font("Inter-Regular", from_file: "priv/fonts/Inter-Regular.ttf"),
heading: Mob.Theme.font("Inter-Bold", from_file: "priv/fonts/Inter-Bold.ttf")
}
)
# in render/1:
%{type: :text, props: %{text: "Section", font: :heading}, children: []}
%{type: :text, props: %{text: "Body copy"}, children: []} # gets :default automaticallyfont_fallback is an ordered list of font names tried, in order, if the
resolved font can't be loaded on-device. Empty by default (the platform's
own system-font fallback still applies) — set it when you want an
explicit intermediate fallback before that.
A font value is either built with Mob.Theme.font/2 (recommended — it
computes the Android name from the actual bundled file) or a bare string
used as-is on both platforms.
Summary
Functions
Build a theme from a keyword list of overrides against the neutral base.
Returns the current OS appearance: :light or :dark.
Return the currently active theme (or the neutral base if none is set).
Return the neutral base theme.
Builds a font token value: the iOS PostScript name as given, paired with
the Android resource name computed from the bundled file via
Mob.Font.android_resource_name/1 — the same function mob_dev's asset
planner uses when it copies the file into res/font/. One computation,
used on both the build side and the theme side, so the two can't drift
apart the way a hand-typed Android name could.
Returns the active theme's palette resolved to ARGB integers — semantic
tokens (:primary, :on_surface, …) walked through the theme's color
map and then through Mob.Renderer.colors/0. Used to push concrete
values to the native side (Mob.Theme.set/1 does this automatically;
callers usually don't need to invoke this directly).
Set the active theme. Accepts
Types
@type color_value() :: atom() | non_neg_integer()
@type t() :: %Mob.Theme{ background: term(), border: term(), error: term(), font_fallback: term(), fonts: term(), glass: term(), muted: term(), on_background: term(), on_error: term(), on_primary: term(), on_secondary: term(), on_surface: term(), primary: term(), radius_lg: term(), radius_md: term(), radius_pill: term(), radius_sm: term(), secondary: term(), space_scale: term(), surface: term(), surface_raised: term(), text_scale: term(), type_scale: term() }
Functions
Build a theme from a keyword list of overrides against the neutral base.
Mob.Theme.build(primary: :emerald_500, type_scale: 1.1)
@spec color_scheme() :: :light | :dark
Returns the current OS appearance: :light or :dark.
Reads from the platform NIF (UITraitCollection.userInterfaceStyle on
iOS, Configuration.uiMode & UI_MODE_NIGHT_MASK on Android). Falls back
to :light when running on the host BEAM (no NIF loaded), on platforms
that don't expose appearance, or on legacy Android apps that haven't
added MobBridge.getColorScheme() yet.
@spec current() :: t()
Return the currently active theme (or the neutral base if none is set).
@spec default() :: t()
Return the neutral base theme.
Builds a font token value: the iOS PostScript name as given, paired with
the Android resource name computed from the bundled file via
Mob.Font.android_resource_name/1 — the same function mob_dev's asset
planner uses when it copies the file into res/font/. One computation,
used on both the build side and the theme side, so the two can't drift
apart the way a hand-typed Android name could.
fonts: %{
heading: Mob.Theme.font("Inter-Bold", from_file: "priv/fonts/Inter-Bold.ttf")
}
@spec resolved_palette(t()) :: %{required(atom()) => non_neg_integer()}
Returns the active theme's palette resolved to ARGB integers — semantic
tokens (:primary, :on_surface, …) walked through the theme's color
map and then through Mob.Renderer.colors/0. Used to push concrete
values to the native side (Mob.Theme.set/1 does this automatically;
callers usually don't need to invoke this directly).
Set the active theme. Accepts:
- A compiled
%Mob.Theme{}struct - A theme module (any module exporting
theme/0, e.g.MobThemes.Obsidian) - A
{module, overrides}tuple - A keyword list of overrides against the neutral base
The module and {module, overrides} forms are remembered (see
Mob.Theme.AdaptiveWatcher): when the module is the registered
follow-OS theme, an OS light/dark flip resolves it again, keeping the
overrides. A struct or keyword list is a fixed theme and is never
re-resolved, even if it equals what the adaptive module returns.