Theming & Branding
The IDK includes a cross-platform theming system that manages colors, typography, spacing, motion, and branding from a single token-based configuration. Themes are defined once and consumed by Jetpack Compose, React, and web applications through platform-specific integrations.
The system is built around design tokens, named values like color.primary or spacing.4 that are resolved at runtime based on scope, variant (light/dark), and accessibility preferences. Instead of scattering hex codes and pixel values throughout your components, you reference tokens by name. The theming system resolves those names to concrete values based on who is viewing the app, which tenant they belong to, and whether they prefer dark mode or high contrast.
Core Concepts
Theme Definitions
A ThemeDefinition is a named collection of tokens:
data class ThemeDefinition(
val id: String,
val name: String,
val variant: ThemeVariant, // LIGHT, DARK, HIGH_CONTRAST
val scope: ThemeScope, // SYSTEM, APP, TENANT, PRINCIPAL
val tokens: List<ThemeToken>,
val parentId: String?, // Inherit from parent definition
val appId: String?,
val version: Long,
)
Definitions are layered by scope. A TENANT definition overrides an APP definition, which overrides SYSTEM defaults. Within each scope, tokens are merged; you only need to define the tokens you want to override.
Theme Tokens
Every visual property is expressed as a token with a type:
data class ThemeToken(
val key: String, // e.g., "color.primary"
val value: String, // e.g., "#6750A4" or "{color.primaryContainer}"
val type: ThemeTokenType, // COLOR, DIMENSION, FONT_FAMILY, SPACING, DURATION, etc.
)
Token values can reference other tokens using {token.key} syntax. References are expanded during resolution, so {color.primary} resolves to whatever the current theme's primary color is.
Token Categories
| Category | Key Pattern | Examples |
|---|---|---|
| Colors | color.* | color.primary, color.onPrimary, color.surface, color.error |
| Typography | typography.* | typography.fontFamily, typography.displayLarge.fontSize, typography.bodyMedium.lineHeight |
| Spacing | spacing.* | spacing.0 (0px) through spacing.48 (192px), 4px grid with 17 stops |
| Border | border.* | border.none, border.thin (1px), border.medium (2px), border.thick (4px) |
| Shape | shape.radius.* | shape.radius.none through shape.radius.full (9999px) |
| Elevation | elevation.* | elevation.none, elevation.xs, elevation.sm through elevation.xl |
| Motion | motion.* | motion.duration.fast (100ms), motion.duration.normal (250ms), motion.easing.standard |
| Branding | branding.* | branding.appName, branding.primaryColor, branding.logoUrl, branding.faviconUrl |
Scope Hierarchy
The scope hierarchy is how the theming system supports multi-tenant white-labeling without duplicating entire theme definitions. Each scope layer only needs to define the tokens it wants to override; everything else falls through to the parent scope. For example, a tenant can change the primary color and logo without redefining every spacing and typography token.
Tokens resolve in order of specificity:
| Scope | Purpose |
|---|---|
SYSTEM | IDK defaults: Material Design 3 baseline theme |
APP | Application-level branding: your app's color scheme and typography |
TENANT | Tenant-level overrides: white-labeling for multi-tenant applications |
PRINCIPAL | User-level preferences: individual user customizations |
Lower scopes override higher ones. A tenant can override the app's primary color without redefining the entire theme.
Variants
Each scope can define tokens for multiple variants. You do not need to define all three; the resolver falls back to LIGHT if a variant-specific token is missing. The HIGH_CONTRAST variant is specifically for accessibility, where contrast ratios are boosted beyond the standard WCAG AA thresholds.
LIGHT: standard light themeDARK: dark themeHIGH_CONTRAST: accessibility variant with boosted contrast ratios
The active variant is selected at runtime based on platform preferences (e.g., prefers-color-scheme on web, system dark mode on iOS/Android).
Color Configuration
The theming system supports four approaches to color configuration, from simple to fully custom:
Seed Color
Generate a full Material Design 3 palette from a single brand color:
val colorConfig = ThemeColorConfig.SeedColor(
seed = "#1a73e8" // Your brand color
)
The seed is converted to the HCT (Hue, Chroma, Tone) color space to generate tonal palettes for primary, secondary, tertiary, neutral, and error roles.
Multi-Seed
Provide separate seed colors for each role:
val colorConfig = ThemeColorConfig.MultiSeed(
primary = "#1a73e8",
secondary = "#34a853",
tertiary = "#fbbc04",
neutral = "#5f6368",
error = "#ea4335",
)
Explicit Palettes
Hand-craft every palette stop. This is useful when your design system specifies exact values (e.g., exported from Figma):
val colorConfig = ThemeColorConfig.ExplicitPalettes(
palettes = DesignSystemPalette(
brand = PaletteScale(
s50 = "#e8f0fe", s100 = "#d2e3fc", s200 = "#aecbfa",
s300 = "#8ab4f8", s400 = "#669df6", s500 = "#4285f4",
s600 = "#1a73e8", s700 = "#1967d2", s800 = "#185abc",
s900 = "#174ea6"
),
// secondary, neutral, error, success, warning, info, pending...
)
)
Palette scales use role-based names (brand, secondary, neutral) rather than color names (blue, green, gray) to keep the design system independent of specific hues.
Hybrid
Use explicit palettes for brand colors (from your design system) and M3-generated palettes for everything else:
val colorConfig = ThemeColorConfig.Hybrid(
explicitPalettes = DesignSystemPalette(
brand = myBrandPalette,
// only define the roles you have explicit values for
),
fallbackSeed = "#1a73e8" // M3 generation for missing roles
)
Theme Resolution
The ThemeResolver combines definitions from all scopes into a ResolvedTheme:
val resolver: ThemeResolver = session.graph.themeResolver
val resolved: ResolvedTheme = resolver.resolve(
variant = ThemeVariant.LIGHT,
scope = ThemeScope.TENANT,
tenantId = "acme-corp"
)
// Access resolved tokens
val primaryColor: String? = resolved.tokens["color.primary"] // "#1a73e8"
val fontFamily: String? = resolved.tokens["typography.fontFamily"]
val branding: BrandingMetadata = resolved.branding
Resolution applies token references, flattens scope layers, and validates the final token set.
The ThemeResolver and ThemeStore are session-scoped and injected via Metro:
@ContributesBinding(SessionScope::class, binding = binding<ThemeResolver>())
class DefaultThemeResolver(private val store: ThemeStore) : ThemeResolver
Compose Integration
On Android and Kotlin Multiplatform, the Compose integration translates IDK tokens into native Material 3 types (ColorScheme, Typography, Shapes). Your composables use standard M3 APIs like MaterialTheme.colorScheme.primary and the values come from the IDK token system automatically.
The lib-conf-theme-compose module maps resolved tokens to Jetpack Compose's Material 3 theme system.
Basic Setup
@Composable
fun MyApp() {
DefaultTheme {
// Material 3 theme is configured from IDK tokens
// All M3 components use the themed colors, typography, and shapes
Scaffold {
Text("Themed content")
}
}
}
With zero configuration, DefaultTheme uses the M3 baseline (purple seed, #6750A4). To apply your brand:
@Composable
fun MyApp() {
DefaultTheme(
colorConfig = ThemeColorConfig.SeedColor("#1a73e8"),
appName = "My App",
logoUrl = "https://example.com/logo.svg"
) {
MyContent()
}
}
CompositionLocals
DefaultTheme provides several CompositionLocal values that components can read:
| Local | Type | Content |
|---|---|---|
LocalThemeTokens | Map<String, String> | Raw flat token map |
LocalResolvedTheme | ResolvedTheme? | Full resolved theme object |
LocalThemeVariant | ThemeVariant? | Current variant (light/dark) |
LocalBrandingTokens | BrandingTokens | App name, logo URLs, primary color |
LocalSpacingTokens | SpacingTokens | Spacing scale values |
LocalMotionTokens | MotionTokens | Animation durations and easings |
LocalShadowTokens | ShadowTokens | Elevation shadow values |
LocalBorderTokens | BorderTokens | Border width and radius values |
LocalPaletteTokens | PaletteTokens | Raw palette primitives (tier 0) |
LocalAccessibilityState | AccessibilityState | OS accessibility preferences |
LocalWindowWidthSizeClass | WindowWidthSizeClass | Responsive breakpoint class |
Token Mappers
Under the hood, DefaultTheme uses a pipeline of mappers to convert IDK tokens into Compose types:
ThemeTokenMapper: tokens to M3ColorSchemeTypographyTokenMapper: tokens to M3Typography(font size, weight, line height, letter spacing)ElevationTokenMapper: tokens to elevation valuesSpacingTokenMapper/ShadowTokenMapper/BorderTokenMapper/MotionTokenMapper: tokens to structured data classes
Accessibility
The Compose integration automatically adapts to platform accessibility settings:
- High contrast mode:
HighContrastTokenResolverboosts color contrast ratios when the OS reports high-contrast preference - Reduced motion:
MotionTokensreflect the user's reduced-motion preference - Adaptive sizing:
AdaptiveTokenResolveradjusts tokens based onWindowWidthSizeClassfor responsive layouts
UI Components
The lib-ui-compose module provides pre-built Compose components that consume theme tokens:
| Component | Variants |
|---|---|
Button | Primary, Secondary, Ghost, Outline |
Card | Default, interactive (with click) |
Input | Text input with focus states |
Select | Dropdown select with menu |
Checkbox | Checked/unchecked with label |
Radio / RadioGroup | Single and grouped selection |
Badge | Primary, Error |
Modal | Dialog overlay with backdrop |
Tabs | Tab bar with active indicator |
Toast | Primary, Success, Error, Warning, Info |
BlobExplorer | File tree browser component |
Components are styled through a ComponentTheme composable that maps resolved tokens to component-specific token data classes:
@Composable
fun MyApp() {
DefaultTheme(colorConfig = ThemeColorConfig.SeedColor("#1a73e8")) {
ComponentTheme {
// Components now read their tokens from CompositionLocals
Button(onClick = { }) { Text("Submit") }
Card { Text("Card content") }
}
}
}
Each component reads its styling from a dedicated CompositionLocal (e.g., LocalButtonTokens, LocalCardTokens), so you can override individual component tokens without affecting others.
Web and React Integration
The web integration takes a different approach from Compose. Instead of mapping tokens to framework-specific theme objects, it generates CSS custom properties (e.g., --color-primary) that you consume in your stylesheets. This means the same token definitions drive both Compose and web UIs, but the integration mechanism matches what each platform expects.
React ThemeProvider
The React theme SDK provides a ThemeProvider that generates CSS custom properties from the same token system:
import { ThemeProvider } from '@sphereon/theme-react';
function App() {
return (
<ThemeProvider
colorConfig={{ type: 'seed', seed: '#1a73e8' }}
appName="My App"
logoUrl="/logo.svg"
defaultMode="system"
>
<MyContent />
</ThemeProvider>
);
}
The provider:
- Generates M3 palettes from the color config (same HCT algorithm as the Kotlin implementation)
- Injects CSS custom properties onto
:root(e.g.,--color-primary,--spacing-4) - Resolves token references
- Persists the selected mode (light/dark) in
localStorage - Respects
prefers-color-schememedia query when mode is"system"
CSS Custom Properties
IDK token keys map to CSS variables by replacing dots with dashes:
| Token Key | CSS Variable |
|---|---|
color.primary | --color-primary |
typography.bodyMedium.fontSize | --typography-body-medium-font-size |
spacing.4 | --spacing-4 |
motion.duration.normal | --motion-duration-normal |
Use them in your CSS or styled components:
.my-button {
background-color: var(--color-primary);
padding: var(--spacing-3) var(--spacing-4);
border-radius: var(--shape-radius-md);
transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
}
Kotlin/JS Web Utilities
The lib-conf-theme-web module provides Kotlin/JS utilities for server-rendered or non-React web applications:
CssTokenMapper: converts token keys to CSS variable names and handles unit conversion (dp/sptopx)WebSystemDefaults: pre-resolved token maps for light and dark baselinesFoucPreventionScript: generates a blocking inline<script>that applies the stored theme mode before first paint, preventing flash of unstyled content
React UI Components
A parallel React component library mirrors the Compose components, styled with CSS modules that read from the injected CSS custom properties:
- Button, Card, Input, Select, Checkbox, Radio, RadioGroup
- Modal, Tabs, Badge, Toast
- BlobExplorer
Branding
The branding subsystem provides structured metadata for application chrome (logo, app name, favicon, and custom fonts):
data class BrandingMetadata(
val appName: String?,
val primaryColor: String?,
val logoUrl: String?,
val logoDarkUrl: String?, // Separate logo for dark mode
val faviconUrl: String?,
val fontResourceId: String?, // Custom font reference
val logoResourceId: String?,
val logoDarkResourceId: String?,
)
Branding tokens are set through the standard token system (branding.appName, branding.logoUrl, etc.) and resolved alongside other tokens. In Compose, access them via LocalBrandingTokens; in React/web, read the corresponding CSS variables or the ThemeContext.
For web applications, WebBrandingMetadata adds web-specific fields:
data class WebBrandingMetadata(
val customCssUrl: String?, // External CSS for further customization
val fontStylesheetUrls: List<String>, // Google Fonts or self-hosted font URLs
val assetBaseUrl: String?, // Base URL for logo/favicon assets
)
Custom CSS is sanitized with a configurable policy (CssPolicyConfig) that blocks dangerous selectors and restricts allowed properties.
Multi-Tenant White-Labeling
The scope hierarchy makes multi-tenant white-labeling straightforward. Define your base theme at APP scope, then override per tenant:
val store: ThemeStore = session.graph.themeStore
// App-level base theme
store.save(ThemeDefinition(
id = "app-light",
name = "App Light",
variant = ThemeVariant.LIGHT,
scope = ThemeScope.APP,
tokens = buildTokens {
color(TokenKeyConstants.COLOR_PRIMARY, "#1a73e8")
string(TokenKeyConstants.BRANDING_APP_NAME, "My Platform")
string(TokenKeyConstants.BRANDING_LOGO_URL, "/logos/platform.svg")
}
))
// Tenant override - only the tokens that differ
store.save(ThemeDefinition(
id = "acme-light",
name = "Acme Corp Light",
variant = ThemeVariant.LIGHT,
scope = ThemeScope.TENANT,
tokens = buildTokens {
color(TokenKeyConstants.COLOR_PRIMARY, "#ff5722")
string(TokenKeyConstants.BRANDING_APP_NAME, "Acme Corp Portal")
string(TokenKeyConstants.BRANDING_LOGO_URL, "/logos/acme.svg")
}
))
When resolving for tenant "acme-corp", the primary color is #ff5722 and the logo is Acme's. All other tokens (typography, spacing, secondary colors) fall through to the app-level definition and system defaults.
Dependencies
For Compose applications:
dependencies {
implementation("com.sphereon.idk:lib-conf-theme-core-public:0.25.0")
implementation("com.sphereon.idk:lib-conf-theme-core-impl:0.25.0")
implementation("com.sphereon.idk:lib-conf-theme-compose:0.25.0")
// Optional: pre-built UI components
implementation("com.sphereon.idk:lib-ui-compose:0.25.0")
}
For web applications, install the React packages:
npm install @sphereon/theme-react @sphereon/ui-react
For Kotlin/JS web applications without React:
dependencies {
implementation("com.sphereon.idk:lib-conf-theme-core-public:0.25.0")
implementation("com.sphereon.idk:lib-conf-theme-web:0.25.0")
}