• Getting Started
    • Overview
    • Why Without JSX?
    • Installation
    • Usage
    • Styling
    • Theming
    • Portal System
    • Rules & Patterns
    • Framework Integration
    • Compiler
    • FAQ
    • Release Notes
  • MUI Integration
  • Components
  • Hooks

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 }) and setTheme are 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.

FieldDescription
modesEvery mode name this app declares. A stored value outside this list is discarded.
defaultModeWhere 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'.
defaultPreferenceWhere a reader who has chosen nothing starts. Omit it and that is defaultMode; 'system' follows the operating system.
systemMaps prefers-color-scheme's light and dark onto two of your modes. Without it, 'system' is not offered.
storageKeyWhere 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. See useTheme.


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 mode and system keys are independently optional — augment whichever you want typed.
  • Without augmentation, behavior is unchanged: any 'theme.…' string is allowed, with no suggestions.
  • The useTheme hook returns the same augmented Theme type, so theme.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
    }
  }
}

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