Styling and theming — styled + PyreonUI
The pattern
Wrap the app in PyreonUI (from @pyreon/ui-core) to provide theme + light/dark mode + config in one provider:
import { PyreonUI } from '@pyreon/ui-core'
import { theme } from './theme' // your own theme object (colors, spacing, …)
<PyreonUI theme={theme} mode="system">
<App />
</PyreonUI>mode accepts "light" | "dark" | "system". "system" reads prefers-color-scheme and tracks live; useMode() returns the resolved mode ('light' | 'dark') as a plain value — call it inside a reactive scope to track changes.
Inline component styles with styled:
import { styled } from '@pyreon/styler'
const Button = styled('button')`
padding: 8px 16px;
background: ${(p) => p.theme.colors.primary};
color: white;
border-radius: 6px;
&:hover {
background: ${(p) => p.theme.colors.primaryDark};
}
`
const Example = () => <Button onClick={() => console.log('click')}>Click me</Button>Or use css for shared snippets + keyframes for animations:
import { css, keyframes } from '@pyreon/styler'
const pulse = keyframes`
from { opacity: 0.5 }
to { opacity: 1 }
`
const flash = css`
animation: ${pulse} 500ms ease infinite alternate;
`Switch theme at runtime by passing a different theme prop to PyreonUI. ThemeContext is a reactive context, but a PLAIN styled() component only picks up the swap automatically when it's driven through @pyreon/rocketstyle or @pyreon/elements (both pass their dimension/layout props as reactive accessors, which is what wires the theme-tracking computed() internally — see styler's Theming docs for the exact mechanism). A hand-written styled() component used directly resolves its CSS once at mount and needs a remount (e.g. behind a <Show>/accessor keyed on the theme) to pick up a later swap.
Why
PyreonUI replaces 3 separate providers (theme / mode / config) with one. It internally calls
init()to wire the CSS engine, so app code never needs to.styledis reactive when rocketstyle/elements-driven — the theme argument is a snapshot at call time for a plainstyled()call, but@pyreon/ui-components(built on rocketstyle) re-resolves on a whole-theme swap without remount, because rocketstyle threads its dimension/theme reads through acomputed()styler tracks.FNV-1a hashing + dedup cache — repeated
styledtemplates with the same CSS resolve to the same class, so identical buttons share one class on the page.
Anti-pattern
// BROKEN — calling init() in app code
import { init } from '@pyreon/ui-core'
init({ styled, css }) // PyreonUI does this internally; duplicate calls
// risk conflicting configs
// Correct:
const Root = () => (
<PyreonUI theme={theme}>
<App />
</PyreonUI>
)// BROKEN — augmenting rocketstyle's ThemeDefault when a theme library you
// depend on already did (causes TS2320 "cannot simultaneously extend")
declare module '@pyreon/rocketstyle' {
interface ThemeDefault extends MyCustomTheme {}
}
// Correct — when the library already augments, consumer code reads the
// augmented type directly without re-declaring it.// BROKEN — reading `theme` with destructure loses reactivity
const { colors } = useTheme()
// If the theme swaps, `colors` is stale.
// Correct — call the accessor inside reactive scopes:
const getTheme = useThemeAccessor()
return <div style={() => ({ color: getTheme().colors.primary })}>...</div>Related
Reference API:
PyreonUI,styled,css,keyframes—get_apiPattern:
reactive-contextfor the theme's underlying reactive context patternAnti-pattern: "Duplicate module augmentation" in
architecturecategory