Theming — MeoNode UI
Context-based theming with automatic value resolution. Define your theme object, wrap your app with ThemeProvider, and
reference theme tokens anywhere using string paths—no prop drilling required.
Theme Structure
A theme has exactly two root properties, and that is the whole type:
interface Theme { mode: ResolvedThemeMode // 'light' | 'dark' | string, until you narrow it system: ResolvedThemeSystem // any token shape you like, until you declare one }
mode— theme mode identifier (light, dark, or your own)system— your design tokens, nested as deeply as you want
Nothing inside system is required, and no token name is special to the
runtime: primary, base and the rest below are a convention this
documentation follows, not fields the library looks for. A theme of
{ mode: 'light', system: { brand: { default: '#000' } } } is as valid as the
one below.
Both mode and system are user-typeable via the MeoTheme interface, which
turns 'theme.…' strings into a checked union instead of any string — see
Typed Theme Tokens below.
The convention this documentation uses, and a reasonable default: a
default/content pair per colour role, so a token carries both the surface
and what is legible on it.
Example Theme:
export const lightTheme: Theme = { mode: 'light', system: { primary: { default: '#FF6B6B', content: '#4A0000', }, secondary: { default: '#6BCB77', content: '#0A3B0F', }, accent: { default: '#4ECDC4', content: '#1A4A47', }, warning: { default: '#FFE66D', content: '#665A00', }, base: { default: '#F8F8F8', content: '#333333', accent: '#88B04B', }, }, // Add custom properties for your design system }
ThemeProvider
Wrap your app once. ThemeProvider is what makes theme.* token paths resolve anywhere below it, with no prop
passing:
import { ThemeProvider, Column, H1, Text } from '@meonode/ui' import { themeTokens } from './tokens' const AppContent = () => Column({ padding: 20, children: [ H1('Themed Application', { color: 'theme.primary', fontSize: '2.5rem' }), Text('Automatic theme resolution', { backgroundColor: 'theme.base', color: 'theme.base.content', padding: 10, }), ], }).render() const App = () => ThemeProvider({ modes: ['light', 'dark'], defaultMode: 'light', tokens: themeTokens, children: AppContent(), }).render()
A single-theme application declares one mode — modes: ['light'], defaultMode: 'light' — and never offers a switch.
There is no separate single-theme provider to learn.
tokens, modes and defaultMode are required. Leaving one out, or naming a defaultMode that modes does not
list, throws an error that names the prop — in production builds too, since a caller TypeScript never checked is most
likely running one.
Upgrading from v2?
ThemeProvider({ theme })andsetThemeare gone. See Migrating from v2.
Theme Modes
Until v3 the provider took a whole theme object, so a server-rendered page had to know which theme to send — from a cookie, or by guessing. A cookie makes the response depend on who asked for it, so no cache can share it; a guess is a flash of the wrong theme for everyone it got wrong.
ThemeProvider is configured with mode names instead. Colours are var() references, so one token object serves
every mode and the server sends the same bytes to everyone. Which palette applies is decided by a data-theme
attribute on <html>, written by a small script before the first paint — so there is no flash, because there was
never a wrong first paint to correct.
One configuration, two halves
import type { ThemeScriptConfig } from '@meonode/ui' export const themeConfig = { modes: ['morning', 'night'], defaultMode: 'morning', defaultPreference: 'system', system: { light: 'morning', dark: 'night' }, } satisfies ThemeScriptConfig
Mode names are yours. 'light' and 'dark' are not required — system is what says which of your modes the
operating system's two words mean.
| Field | Description |
|---|---|
modes | Every mode name this app declares. A stored value outside this list is discarded. |
defaultMode | Where everything falls back to when there is no other answer — a stored mode you have since removed, a matchMedia that throws, blocked storage. It must name a mode your stylesheets define, which is why it cannot itself be 'system'. |
defaultPreference | Where a reader who has chosen nothing starts. Omit it and that is defaultMode; 'system' follows the operating system. |
system | Maps prefers-color-scheme's light and dark onto two of your modes. Without it, 'system' is not offered. |
storageKey | Where the choice is kept. Defaults to theme. |
defaultPreference and defaultMode answer different questions — where to begin, against where to end up when
everything else fails. A site can sensibly want "follow the operating system, but if you cannot tell, night".
The pre-paint script
import { Body, Head, Html, themeScript } from '@meonode/ui' import { themeConfig } from '@src/constants/themes/config' Html({ lang: 'en', suppressHydrationWarning: true, children: [ Head({ key: 'theme', children: themeScript(themeConfig) }), Body({ key: 'body', children: /* … */ }), ], })
Place it first in <head>, before any stylesheet — a script after a <link rel="stylesheet"> cannot run until
that sheet loads, which is the delay it exists to avoid. suppressHydrationWarning is needed because the script
writes an attribute the server did not send.
The script body is a constant: your configuration travels in a data- attribute rather than being baked into the
source, so it has one Content-Security-Policy hash forever. If your policy pins hashes, use the exported constant
rather than copying the value:
import { THEME_SCRIPT_CSP_HASH } from '@meonode/ui' `script-src '${THEME_SCRIPT_CSP_HASH}' 'self'`
A copied literal goes stale the first time the body changes, and it fails silently: the policy is still valid, the
tag is still in the document, and the page still ends up in the right mode because the provider repairs the
attributes after mounting. The only symptom is that data-theme arrives after hydration instead of before — which
is exactly the flash the script exists to prevent, and invisible to any check that runs after hydration.
Tokens and palettes
export const themeTokens = { base: { default: 'var(--base-default)', content: 'var(--base-content)' }, primary: { default: 'var(--primary-default)', content: 'var(--primary-content)' }, }
:root { --base-default: #fffdf7; --base-content: #1a1a24; } [data-theme='night'] { --base-default: #0a0a0f; --base-content: #f0f0f5; }
Put a usable palette on :root, with [data-theme='…'] blocks as overrides — not palettes that exist only under
the attribute. Every failure path in the script ends with no attribute written, and a reader whose storage is blocked
should get a styled page rather than an unstyled one.
The provider
ThemeProvider({ ...themeConfig, tokens: themeTokens, children, })
Spreading the same literal into both halves is the point: a mode added to themeConfig reaches the script and the
provider together, and they cannot disagree about which modes exist.
Naming your modes in types
Augment MeoTheme and setMode will autocomplete your own mode names and reject a typo:
declare module '@meonode/ui' { interface MeoTheme { mode: 'morning' | 'night' } }
Without it, any string is accepted — which matters most for modes itself, where a misspelt name is otherwise
unreachable by any check: it is consistent with itself, so every runtime validation passes and the only symptom is a
stylesheet that matches nothing.
Do not render markup from
mode. The server does not know the reader's mode, so a component that branches on it disagrees with the server during hydration for everyone not on the default — and React answers that by re-rendering the whole page on the client, clearing the attributes the script wrote. SeeuseTheme.
Accessing Theme Values
String Path Notation
Reference theme tokens using dot-separated paths prefixed with theme.:
import { Column, Button } from '@meonode/ui' Column({ backgroundColor: 'theme.primary', padding: 'theme.spacing.md', children: Button('Submit', { backgroundColor: 'theme.secondary', color: 'theme.secondary.content', }), })
Default Key Resolution:
Partial paths (e.g., 'theme.primary') automatically resolve to the default key within that object.
// These are equivalent: backgroundColor: 'theme.primary' backgroundColor: 'theme.primary.default'
Tokens work inside a fallback list too, each entry resolving on its own: fontFamily: ['Arial', 'theme.font.body'].
If the object has no default key, the path resolves to nothing rather than throwing. The declaration still emits, but
it references a CSS variable that was never defined, so the browser drops that one property; nothing else about the node
is affected. In development you get a warning naming the path and suggesting the keys that do exist. Augmenting
MeoTheme catches it earlier still, at compile time — see Typed Theme Tokens.
Numbers work, and mean the same thing they do as a literal.
padding: 16 and padding: 'theme.spacing.md' with md: 16 both produce 16px. Length-valued properties resolve a
numeric token with its unit; properties where a bare number is meaningful in its own right — lineHeight, zIndex,
flexGrow, opacity — keep the raw value:
const system = { spacing: { md: 16 }, text: { tight: 1.25 }, layer: { modal: 40 } } Div({ padding: 'theme.spacing.md', // 16px — a length lineHeight: 'theme.text.tight', // 1.25 — a ratio, not 1.25px zIndex: 'theme.layer.modal', // 40 })
The same token resolves differently depending on where it is used, decided by the property rather than the value. Units
in the token still work exactly as written ('1rem', '50%', '0.5em'), and are clearer when a scale is shared across
properties.
One exception: breakpoint tokens must carry their unit.
CSS variables are invalid inside a media feature, so a token used in a media query resolves to literal text rather than
a variable — there is nothing to attach a unit to. @media (max-width: 768) is not a valid feature and the browser
drops the entire block, not one declaration:
const system = { breakpoint: { md: '768px' } } // ✅ with the unit // const system = { breakpoint: { md: 768 } } // ❌ whole @media block is ignored Div({ css: { '@media (max-width: theme.breakpoint.md)': { padding: 'theme.spacing.sm' } }, })
Development warns about this one specifically, since a silently missing responsive block is easy to misread as a layout bug.
Direct Object Access
Use the useTheme hook for direct access with full TypeScript support:
import { useTheme, Column, Text } from '@meonode/ui' const ThemedComponent = () => { const { theme } = useTheme() return Column({ padding: theme.system.spacing.md, backgroundColor: theme.system.base.default, children: Text('Direct access', { color: theme.system.base.content, }), }).render() }
Theme Functions in css Prop
Pass functions to css prop properties for dynamic theme-based computations:
import { Div } from '@meonode/ui' import tinycolor from 'tinycolor2' Div({ backgroundColor: 'theme.primary', css: { // Simple theme access color: theme => theme.system.primary.content, // Computed values boxShadow: theme => `0 4px 14px 0 ${tinycolor(theme.system.primary.default).setAlpha(0.38).toString()}`, }, })
This provides maximum flexibility for complex styling logic based on theme tokens.
A function can also return the whole css value, or be one entry of a css array:
Div({ css: theme => ({ color: theme.system.primary.content, borderColor: theme.system.primary.default, }), })
Theme functions run only under a ThemeProvider. With none above the node, the function is dropped and nothing is
emitted for it; development warns, naming the property it was written for when there is one.
Typed Theme Tokens
By default, any 'theme.<anything>' string is accepted on style props, and you get no autocomplete.
Augment the MeoTheme interface once in your project (e.g. in a meonode.d.ts file at your src root) to get autocomplete on token paths and a typed theme object wherever you receive one:
declare module '@meonode/ui' { interface MeoTheme { mode: 'light' | 'dark' | 'sepia' system: { primary: { default: string; content: string } secondary: { default: string; content: string } base: { default: string; content: string } spacing: { sm: string; md: string; lg: string } } } }
Once declared, your token paths join the suggestions on every style prop, and
theme.mode / theme.system reflect your shape wherever a Theme is handed to
you:
import { Div, useTheme } from '@meonode/ui' // ✅ Autocompleted from your own theme Div({ color: 'theme.primary', // suggested backgroundColor: 'theme.base', // suggested padding: 'theme.spacing.md', // suggested }) // ✅ Typed mode + system in function form — a wrong key or a bad mode // comparison is a compile error here Div({ css: { color: theme => theme.mode === 'sepia' ? theme.system.primary.default : theme.system.secondary.default, }, })
What augmentation does and does not catch. Style props accept any string by
design — that is what keeps every plain CSS value ('red', 'flex', '1px solid')
working beside token strings. So a misspelled token is not a compile error:
Div({ color: 'theme.primry' }) // compiles. Caught at runtime, not by the type.
Development catches it instead, naming the path, the property it was used for,
and the keys that do exist — theme.primry (used for \color`) is not defined by
the active theme. Did you mean one of: …`. The type system's job here is
suggesting the right path, and the runtime's job is telling you when you did not
take the suggestion.
Typed positions are stricter, because they are not style props: the theme
argument in a function value, the object you pass to ThemeProvider, and
anything you read off useTheme() are all checked against your declared shape.
Notes:
- Both
modeandsystemkeys are independently optional — augment whichever you want typed. - Without augmentation, behavior is unchanged: any
'theme.…'string is allowed, with no suggestions. - The
useThemehook returns the same augmentedThemetype, sotheme.system.<path>autocompletes and type-checks.
Function Children
Function children automatically inherit theme context:
import { ThemeProvider, Column } from '@meonode/ui' ThemeProvider({ modes: ['light'], defaultMode: 'light', tokens: myTokens, children: () => Column({ color: 'theme.accent', padding: 'theme.spacing.md', }), })
The child function's returned nodes receive the theme automatically.
Complete Example
import { ThemeProvider, Column, H1, Button, Text } from '@meonode/ui' const lightTheme = { mode: 'light', system: { primary: { default: '#3B82F6', content: '#FFFFFF' }, secondary: { default: '#10B981', content: '#FFFFFF' }, base: { default: '#F9FAFB', content: '#1F2937' }, spacing: { sm: '8px', md: '16px', lg: '24px' }, }, } const ThemedApp = () => Column({ padding: 'theme.spacing.lg', backgroundColor: 'theme.base', children: [ H1('MeoNode Theming', { color: 'theme.primary', marginBottom: 'theme.spacing.md', }), Text('Context-based theme system with automatic resolution', { color: 'theme.base.content', marginBottom: 'theme.spacing.md', }), Button('Primary Action', { backgroundColor: 'theme.primary', color: 'theme.primary.content', padding: '12px 24px', borderRadius: 8, }), ], }).render() const App = () => ThemeProvider({ modes: ['light', 'dark'], defaultMode: 'light', tokens: themeTokens, children: ThemedApp(), }).render() export default App
Switching Modes
There is no theme object to swap. A mode is a name; what it looks like is a [data-theme='…'] block in your
stylesheet. So switching is naming the other mode:
import { Button, useTheme } from '@meonode/ui' const ThemeToggle = () => { // `mode` is read in the handler, never rendered into markup — see below. const { mode, setMode } = useTheme() return Button('Toggle theme', { onClick: () => setMode(mode === 'dark' ? 'light' : 'dark'), }).render() }
setMode stores the choice and stamps <html>, so it survives a reload and the pre-paint script applies it before
anything is painted. setPreference('system') hands control back to the operating system.
Nesting
Nesting ThemeProvider still works and still scopes token resolution to a subtree. What it no longer does is give
that subtree a different mode — the mode is a document-level attribute, so a nested provider sharing the same
token names resolves to the same variables. Nest to supply different tokens, not a different mode.
Best Practices
Structure system Property
Organize design tokens logically: colors, spacing, typography, shadows, borders.
Use Semantic Names
primary, secondary, success, warning over blue, green.
Default Keys for Variants
Use default as the base value, add content, hover, active for variants.
const system = { primary: { default: '#3B82F6', content: '#FFFFFF', hover: '#2563EB', active: '#1D4ED8' } }
TypeScript for Autocomplete
Augment the MeoTheme interface to get your own token paths suggested on every style prop, and a typed theme wherever you receive one — see Typed Theme Tokens.
Function-Based Computations
Use theme functions in css prop for dynamic color transformations or complex calculations.
Common Patterns
Color System
const system = { // Core colors primary: { default: '#3B82F6', content: '#FFFFFF' }, secondary: { default: '#10B981', content: '#FFFFFF' }, // Semantic colors success: { default: '#10B981', content: '#FFFFFF' }, warning: { default: '#F59E0B', content: '#78350F' }, error: { default: '#EF4444', content: '#FFFFFF' }, // Surface colors base: { default: '#FFFFFF', content: '#1F2937' }, surface: { default: '#F9FAFB', content: '#374151' }, overlay: { default: 'rgba(0, 0, 0, 0.5)', content: '#FFFFFF' } }
Spacing Scale
const system = { spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem', xl: '2rem', '2xl': '3rem' } }
Typography
const system = { typography: { fontFamily: { sans: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto', mono: 'ui-monospace, SFMono-Regular, Menlo' }, fontSize: { sm: '0.875rem', base: '1rem', lg: '1.125rem', xl: '1.25rem', '2xl': '1.5rem' }, fontWeight: { normal: 400, medium: 500, semibold: 600, bold: 700 } } }
Related FAQ
How does the theming system work?
Wrap your app with ThemeProvider, define tokens under theme.system, and reference values using token paths like theme.primary or through useTheme().
How does styling work in MeoNode UI?
Styling is prop-driven and theme-aware. Use direct CSS props for simple styles, and use the css prop for selectors, queries, and keyframes.
More details: /docs/getting-started/faq
Next Steps
- Framework Integration — Next.js and Vite setup, including SSR and the build-time compiler
- FAQ — Common patterns, troubleshooting, migration strategies
- Release Notes — Changelog, breaking changes, upgrade guides
On this page
- Theming — MeoNode UI