Theming
Change role tokens deliberately, preserve foreground/background pairs and keep overlays inside the intended scope.
Token layers and authority
Primitive tokens supply values; semantic tokens express roles such as primary, background or destructive. Component TypeScript presets are generated from explicit selector/property bindings to the stylesheet. They describe authored specifications; content, media/state rules and instance styles determine actual geometry. They are not automatically overridable CSS custom properties. Prefer the existing CSS roles for rendered styling. A palette shade such as colors.blue[600] is not the product primary color.
The stylesheet is the source for the generated TypeScript palette, semantic role values, shadows and other declared tokens as well as this catalogue. semanticColors and darkColors describe authored light/dark values; shadows and darkShadows do the same for elevation. Imports do not change when a user switches theme or an ancestor overrides a role. Use CSS variables for live rendered styling and explicit snapshots for exports or external tools.
| Layer | Example | Scope |
|---|---|---|
| Primitive | --color-ochre-600 | Palette exploration, not automatic action meaning |
| Semantic | --color-primary / --color-primary-foreground | An action and its readable label |
| Component specification | buttonTokens.md.height | Generated source binding; inspect the actual rendered part |
| Instance override | Custom properties on a wrapper | Only descendants that actually inherit the wrapper |
Inspect the TypeScript and CSS contract
Choose any declared token to compare the compiled workspace export with the inherited browser value. Switch the page theme using the navigation control. The local primary override demonstrates why an imported object cannot replace computed CSS.
cssTokens and darkCssTokens contain all authored declarations, keyed by their exact custom-property name. colors includes the ochre, sand, eucalyptus, nightsky and terracotta palettes. spacing keeps numeric keys such as spacing[0.5]; opacity exports decimal strings for JavaScript opacity while the CSS declarations retain percentages for Tailwind color mixing.
fontSize pairs the generated textSizes with separate line-height helpers. fontWeight, lineHeight, letterSpacing, transition.all and white/black constants are JavaScript helpers, not additional CSS declarations. Deprecated error-hover and warning-hover helpers refer to destructive-hover and accent-hover; info-hover retains the info fill. Prefer declared role names for new code.
- Authored light value from TypeScript
#b45e2a- Live inherited CSS value
Reading the rendered stylesheet…
Reading the page theme without a local override.
Values containing var() resolve in the browser. Imported objects are snapshots; they do not watch theme changes, local overrides, fonts or viewport geometry.
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { cssTokens, darkCssTokens } from '@mobtranslate/ui';
type TokenName = keyof typeof cssTokens;
const names = Object.keys(cssTokens) as TokenName[];
/** The compiled workspace exports and live inherited CSS can be inspected separately. */
export function TokenContractExample() {
const [name, setName] = useState<TokenName>('--color-primary');
const [dark, setDark] = useState(false);
const [live, setLive] = useState('Reading the rendered stylesheet…');
const [scoped, setScoped] = useState(false);
const scope = useRef<HTMLDivElement>(null);
useEffect(() => {
const read = () => {
setDark(document.documentElement.classList.contains('dark'));
if (scope.current) setLive(getComputedStyle(scope.current).getPropertyValue(name).trim());
};
read();
const observer = new MutationObserver(read);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
}, [name, scoped]);
return <div ref={scope} data-token-contract className="space-y-4" style={scoped ? { '--color-primary': '#123456' } as CSSProperties : undefined}>
<label className="block" htmlFor="token-contract-name">CSS token</label>
<select id="token-contract-name" className="mt-input w-full" value={name} onChange={(event) => setName(event.target.value as TokenName)}>
{names.map((token) => <option key={token} value={token} data-light={cssTokens[token]} data-dark={darkCssTokens[token]}>{token}</option>)}
</select>
<label className="flex items-start gap-2"><input type="checkbox" checked={scoped} onChange={(event) => setScoped(event.target.checked)} />Override primary inside this example with #123456</label>
<dl className="space-y-2" style={{ overflowWrap: 'anywhere' }}>
<div><dt>Authored {dark ? 'dark' : 'light'} value from TypeScript</dt><dd><code data-token-authored>{(dark ? darkCssTokens : cssTokens)[name]}</code></dd></div>
<div><dt>Live inherited CSS value</dt><dd><code data-token-live>{live || 'Missing CSS declaration'}</code></dd></div>
</dl>
<p role="status">{scoped ? 'Local primary override applied. The authored snapshot remains unchanged.' : 'Reading the page theme without a local override.'}</p>
<p>Values containing var() resolve in the browser. Imported objects are snapshots; they do not watch theme changes, local overrides, fonts or viewport geometry.</p>
</div>;
}
Generate and verify token exports
Edit tokens.css first. The package generator and catalogue use the same PostCSS reader, selecting light defaults and explicit .dark values while excluding the duplicate system-dark fallback. Generated files are committed so private workspace consumers can compile the source package without an install hook. A UI typecheck or build rejects stale TypeScript output. The styleguide build also checks that output before rendering.
pnpm --filter styleguide tokens:generate
pnpm --filter styleguide tokens:check
pnpm --filter @mobtranslate/ui typecheckExplicit theme selection
The stylesheet has light defaults, explicit .dark overrides and a system-dark fallback when neither .light nor .dark is selected. Set both classes deliberately so saved light can override a dark system preference. color-scheme also informs native controls.
The following is the actual controller used by the navigation switcher. It stores explicit choices in mt-theme, removes the key for system preference, catches storage failure and emits a same-tab change event. Call it from browser code.
export type ThemePreference = 'light' | 'dark' | 'system';
export function readThemePreference(): ThemePreference {
try {
const saved = localStorage.getItem('mt-theme');
return saved === 'light' || saved === 'dark' ? saved : 'system';
} catch { return 'system'; }
}
// Call in browser code, never during server rendering.
export function applyTheme(preference: ThemePreference, persist = true) {
const dark = preference === 'dark' || (preference === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const root = document.documentElement;
root.classList.toggle('dark', dark);
root.classList.toggle('light', !dark);
root.style.colorScheme = dark ? 'dark' : 'light';
if (persist) {
try {
if (preference === 'system') localStorage.removeItem('mt-theme');
else localStorage.setItem('mt-theme', preference);
} catch { /* The current tab still gets the selected appearance. */ }
}
window.dispatchEvent(new Event('mt-theme-change'));
return dark;
}
React synchronization and first paint
- Apply the initial preference when browser code initializes. Listen for prefers-color-scheme changes and re-read the saved choice before applying a system change.
- Listen for storage events to reflect choices from another tab and mt-theme-change for other controls in the same tab. Remove all listeners on unmount.
- This documentation applies preference after hydration and may briefly show the default theme on first paint. A product requiring a flash-free first paint needs a CSP-compatible pre-paint strategy or a validated server-readable preference.
- Do not access localStorage during server rendering. Handle unavailable storage without making the interface unusable.
Brand, individual preferences and fallback
Choose which surfaces retain the brand scheme and where a person’s selected or content-derived palette may apply. Keep the established Earth scheme as a dependable fallback; a device wallpaper or operating-system palette is not automatically available to this web app.
A custom role scheme lets components share meaning across brand and user choices. Map complete foreground/background/state relationships, including custom components, instead of substituting a seed color wherever primary appeared. Generated palettes are proposals; inspect actual composition, state and contrast rather than inheriting a claim of automatic accessibility.
The Color Schemes guide provides actual local SDK generation, variants, role/pair inspection, custom-color harmonization and an original-fixture extraction path. Those are opt-in local tools, not a global device-personalization service. The existing page theme controller separately manages light/dark/system preference.
Design-tool styles and implementation handoff
Material’s customization workflow connects existing brand parameters or new source colors to a role scheme, previews them in a design tool, and exports theme information. Additional brand colors can be deliberate custom roles or tonal palettes; record their purpose rather than assuming every color is another primary.
Figma styles, a Theme Builder export, a DSP package, this catalogue’s JSON and scoped CSS are different contracts. Record tool/version, role mapping, format, source and fallback at handoff. Apply styles to actual mock-ups and verify the consuming implementation after migration. This workspace does not claim to run the Figma plugin or generate a DSP package.
Checked local override lab
This wrapper changes a complete eucalyptus action pair and its hover/active roles. The checkbox changes the shared radius role only inside that wrapper. The example does not rewrite the global theme.
Only this wrapper changes. Portalled overlays outside it would not inherit these overrides.
'use client';
import { useId, useState, type CSSProperties } from 'react';
import { Button } from '@mobtranslate/ui';
type TokenStyle = CSSProperties & { [key: `--${string}`]: string };
export function LocalThemeExample() {
const id = useId();
const [rounded, setRounded] = useState(true);
const [status, setStatus] = useState('');
const style: TokenStyle = {
'--color-primary': '#386b4f',
'--color-primary-hover': '#2e5641',
'--color-primary-active': '#274536',
'--color-primary-foreground': '#faf8f5',
'--radius-md': rounded ? 'var(--radius-lg)' : 'var(--radius-none)',
};
return <div className="space-y-4">
<label htmlFor={id} className="flex items-center gap-3 min-h-11">
<input id={id} type="checkbox" checked={rounded} onChange={event => setRounded(event.target.checked)} />
Rounded local action
</label>
<div data-local-theme style={style} className="p-4 border border-[var(--color-border)] rounded-lg">
<Button style={{minHeight:44,height:"auto",minWidth:0,maxWidth:"100%",whiteSpace:"normal",overflowWrap:"anywhere",paddingInline:".5rem"}} onClick={() => setStatus('Local themed action activated. No entry was changed.')}>Try themed action</Button>
</div>
<p role="status" className="text-sm">{status}</p>
<p className="text-sm">Only this wrapper changes. Portalled overlays outside it would not inherit these overrides.</p>
</div>;
}
Override precedence and portals
- Load app overrides after the shared stylesheet and use the appropriate theme selector. A root override alone may lose to explicit .dark values.
- A local custom property reaches DOM descendants through inheritance. React ownership is not CSS inheritance: a portal mounted under body is outside a nested wrapper.
- Apply shared theme scope to an ancestor of both trigger and portal, or use a supported portal container after reviewing the component API. Verify the popup’s computed values.
- className merging does not guarantee that a utility wins over an unlayered component rule. Inspect the cascade; prefer role overrides or an explicit app stylesheet where appropriate.
Contrast and state coverage
- Change the foreground together with its surface and verify the normal, hover, pressed, disabled, focus and error combinations.
- Use the contrast checker for opaque text pairs. Inspect input boundaries, icons and focus indicators separately; text AA alone is not whole-component conformance.
- Keep source/review status meaningful without color. Never recolor a qualified-review badge into a generic decorative success state.
- Test light, dark, forced colors and system preference changes. Reduced motion is a separate preference, not a color theme.
Typography, shape and motion
- A font token requests a stack; load licensed assets and verify glyph coverage before claiming the selected face is in use.
- Choose shape by component role and preserve compact/large layout behavior. A rounder radius should not shrink a hit area.
- Use the declared duration/easing variables. Respect reduced motion and avoid making state completion depend on animation.
- Use the token catalogue’s filtered export for a reviewable override list, rather than copying every token into an app.
Verification and failure cases
- Switch the page theme, navigate and reload; the selected mode and storage value should agree.
- Deny storage, then switch the current tab. The immediate appearance should still change.
- Toggle the local radius lab and inspect its action while an action outside the wrapper retains its original style.
- Open a portalled popup from a locally themed trigger and inspect the popup separately.
- Avoid unexplained overrides, incomplete contrast pairs and hardcoded legacy JavaScript colors that drift from CSS.