Themes and modifiers
← Actions · Documentation · Next: Observers →
Theme layers
BDUI uses semantic tokens instead of product-specific hard-coded styles:
BDUI accessible default
↓ overridden by
host application theme (`BduiConfig`)
↓ overridden by
root widget theme (only when host policy permits)
Each layer deep-merges into the previous one. Missing/invalid values retain the preceding value.
Bdui.init(
BduiConfig {
theme = BduiTheme.default().copy(
colors = BduiTheme.default().colors.copy(
primary = BduiColor(
light = Color(0xFF0057D9),
dark = Color(0xFF8AB4FF),
),
),
)
backendThemePolicy = BduiThemePolicy(
allowOverrides = true,
allowedSections = setOf(
"colors", "typography", "shapes", "spacing", "components",
),
allowedFontFamilies = setOf("brand", "system"),
)
}
)
Tokens
Colors
Typical semantic keys:
primary,onPrimarysecondary,onSecondarysurface,onSurface,surfaceVariant,onSurfaceVariantbackground,onBackgroundoutlineerror,onErrorsuccess,onSuccess
{
"theme": {
"colors": {
"primary": { "light": "#0057D9", "dark": "#8AB4FF" },
"surface": { "light": "#FFFFFF", "dark": "#121212" }
}
}
}
Colors accept #RRGGBB and #AARRGGBB. Which side of each token is used comes from root state.theme: "light" or "dark". If that key is missing, the host system appearance is used.
{
"state": { "theme": "light" },
"theme": {
"colors": {
"onSurface": { "light": "#111111", "dark": "#F4F4F5" }
}
}
}
Patch $root/state/theme to "dark" to switch appearance.
Typography
Typical keys:
displayLarge,displayMedium,displaySmallheadlineLarge,headlineMedium,headlineSmalltitleLarge,titleMedium,titleSmallbodyLarge,bodyMedium,bodySmalllabelLarge,labelMedium,labelSmall
Each token may contain host-allowed fontFamily, fontSize, fontWeight, lineHeight, and letterSpacing.
{
"theme": {
"typography": {
"titleLarge": {
"fontFamily": "brand",
"fontSize": 22,
"fontWeight": 600,
"lineHeight": 28
}
}
}
}
Backend JSON cannot supply executable/downloadable fonts. Font family names resolve only to resources registered by the host. User font scale always applies.
Shapes
Typical keys: extraSmall, small, medium, large, extraLarge.
{
"theme": {
"shapes": {
"small": { "cornerRadius": 8 },
"medium": { "cornerRadius": 12 }
}
}
}
Spacing
Semantic spacing avoids repeating product constants:
{
"theme": {
"spacing": {
"xs": 4,
"sm": 8,
"md": 16,
"lg": 24,
"xl": 32
}
}
}
Use as compatible paths:
{
"name": "paddingHorizontal",
"value": "$theme.spacing.md"
}
Components and variants
Component tokens define coherent variants:
| Widget | Default | Examples |
|---|---|---|
Button | primary | secondary, tertiary, danger |
Card | default | outlined, elevated |
TextField | outlined | filled |
Chip | filter | assist, input |
Text | bodyMedium | typography token names |
{
"theme": {
"components": {
"Button": {
"primary": {
"containerColor": "$theme.colors.primary",
"contentColor": "$theme.colors.onPrimary",
"shape": "$theme.shapes.medium"
},
"danger": {
"containerColor": "$theme.colors.error",
"contentColor": "$theme.colors.onError"
}
}
}
}
}
Resolution:
- explicit widget param;
- selected
$theme.components.<Widget>.<variant>; - BDUI widget default.
Widgets derive enabled, pressed, focused, selected, and error states from component state tokens. The backend does not manually send per-interaction colors.
Using theme paths
Theme and state paths are separate:
{
"type": "Text",
"params": {
"text": "$root.title",
"color": "$theme.colors.primary",
"fontSize": "$theme.typography.titleLarge.fontSize"
}
}
An unresolved theme token falls back to the previous layer or the widget default.
Backend safety policy
The host controls:
- whether backend overrides are allowed;
- allowed sections/tokens;
- registered font family names;
- color, size, radius, spacing, and typography bounds;
- URL/asset policy where component tokens can refer to assets.
Reject and log:
- malformed/non-finite values;
- negative dimensions;
- typography outside configured bounds;
- unknown font family;
- token-reference cycles;
- unapproved sections;
- remote executable assets.
Do not fail rendering or trigger action error observers for rejected style values.
Theme overrides
patch updates the backend theme layer on $root/theme. merge adds/overrides tokens; replace replaces the backend layer. Host BduiThemePolicy filters sections/tokens. Rejected tokens are logged and dropped; the patch still succeeds.
{
"type": "patch",
"patch": [
{
"op": "merge",
"path": "$root/theme",
"value": {
"colors": {
"primary": { "light": "#0057D9", "dark": "#8AB4FF" }
}
}
}
]
}
See patch. A later root snapshot or patch that replaces $root may replace the root theme.
Modifiers
Envelope modifier is an ordered array applied left-to-right. It is not a param:
{
"modifier": [
{ "name": "width", "value": "40" },
{ "name": "height", "value": "40" },
{ "name": "paddingHorizontal", "value": "16" }
]
}
Size values are dp, not device pixels. A 40 width is 40 density-independent pixels on every platform. Physical px is not part of the vocabulary: it would ignore screen density and break accessibility scaling.
| Name | Value | Compose behavior |
|---|---|---|
fillMaxWidth | unused | fillMaxWidth() |
fillMaxHeight | unused | fillMaxHeight() |
width | dp/path | width(value.dp) — exact width |
height | dp/path | height(value.dp) — exact height |
minWidth | dp/path | widthIn(min = value.dp) |
minHeight | dp/path | heightIn(min = value.dp) |
maxWidth | dp/path | widthIn(max = value.dp) |
maxHeight | dp/path | heightIn(max = value.dp) |
padding | dp/path | padding(value.dp) on all sides |
paddingVertical | dp/path | padding(vertical = value.dp) |
paddingHorizontal | dp/path | padding(horizontal = value.dp) |
paddingStart | dp/path | RTL-aware start padding |
paddingTop | dp/path | top padding |
paddingEnd | dp/path | RTL-aware end padding |
paddingBottom | dp/path | bottom padding |
background | color/path | #RRGGBB / #AARRGGBB fill. Put clip before background to round it |
clip | dp/path | clip(RoundedCornerShape(value.dp)) |
weight | number/path | Row/Column child flex; ignored elsewhere |
width / height set a fixed size. Use min* / max* when the widget may grow or shrink. Combining width with fillMaxWidth is order-dependent and usually a mistake — pick one. Negative and non-finite sizes are treated as absent.
Order matters:
{
"modifier": [
{ "name": "paddingHorizontal", "value": "16" },
{ "name": "fillMaxWidth" }
]
}
is not necessarily equivalent to filling first and padding second.
Unknown modifier names are ignored and logged. A missing modifier produces bare Modifier. Invalid/negative dimensions are clamped or ignored according to host policy.