Composium is an Android Jetpack Compose library for building an in-app UI catalog and Storybook-like scene browser. It lets you register Compose UI states as scenes, browse components inside your app, tweak parameters at runtime, and maintain a living playground for design systems, QA, and visual review.
One of the core ideas of the library is that it gives you a ready-to-embed ComposiumScreen() composable. You can place this screen anywhere in your app: in a debug-only route, a separate activity, an internal tools section, or any custom navigation graph. ComposiumScreen() will render the scenes that you described in your project and turn them into a browsable interactive catalog.
Version: 1.3.0-alpha01 (pre-release)
Artifacts:
io.github.oleginvoke:composium:1.3.0-alpha01io.github.oleginvoke:composium-processor:1.3.0-alpha01
It is useful for:
- design systems and component libraries;
- interactive state catalogs for Compose components;
- QA and visual review of edge cases;
- quick local experiments when regular
@Previewis not enough; - keeping a living UI playground inside the app.
Composium is intentionally flexible:
- you can place
ComposiumScreen()anywhere in your app; - supports any parameter type, including nullable types;
- scenes are just Compose code;
- KSP is recommended, but not required;
- scenes can be flat or deeply grouped;
- grouping depth is unlimited;
- you can use the built-in theme toggle or fully own theme state yourself;
- you can create project-local scene helpers for shared preview chrome;
- you can describe parameters with automatic inference where possible and explicit options where needed.
The library is meant to help you explore UI, not constrain how you structure it.
- Storybook-style scene browser for Android Compose
- Searchable scene list
- Flat scenes and unlimited nested groups via
group = "A/B/C" - Automatic scene discovery with KSP
- Optional manual registration without KSP
- Automatic scene thumbnail generation for the main catalog
- Custom scene thumbnails via the
thumbnailparameter - Scene card badges via the
badgeparameter - Runtime controls for scene parameters
- Automatic controls for
Boolean,String,enum, and sealed object hierarchies - Nullable parameter support with explicit null-state toggle
- Custom names for selectable options
- Custom ordering for auto-inferred sealed options
- Built-in dark theme toggle
- External theme ownership when you need full control
- Preview system controls for dark theme, display size, font size, and RTL
- Android
minSdk 24 - Consumer project
compileSdk 36 - JVM target
11 - Kotlin
2.3.21or newer mavenCentral()andgoogle()in your consumer project
If you use KSP, choose a KSP2 plugin version compatible with your Kotlin and AGP versions.
The baseline configuration uses Kotlin 2.3.21, KSP 2.3.9, AGP 8.10.1, and Gradle 8.11.1.
Set your app or host module to compileSdk 36 before adding Composium.
Add repositories:
repositories {
google()
mavenCentral()
}plugins {
id("com.google.devtools.ksp") version "<ksp-version>"
}
dependencies {
implementation("io.github.oleginvoke:composium:1.3.0-alpha01")
ksp("io.github.oleginvoke:composium-processor:1.3.0-alpha01")
}Use this mode when you want automatic scene collection.
In a multi-module project, keep all scene declarations and the Composium KSP processor in a single showcase module, such as :sample or :catalog. That module depends on the modules containing the UI components and declares scenes for those components. It can run as a standalone sample app or expose a public composable entry point for a host app to display the showcase.
Automatic discovery collects scenes declared in that showcase module; it does not collect annotated scenes from compiled dependencies. Do not configure the Composium processor in multiple scene-containing modules included in the same app: each generates the same registry class, causing a duplicate-class build error. The runtime dependency itself can be used in multiple modules; this restriction applies to scene discovery and registry generation.
dependencies {
implementation("io.github.oleginvoke:composium:1.3.0-alpha01")
}Use this mode when you do not want to add KSP to the consumer project. In this case:
- do not add the processor;
- do not use
@ComposiumSceneor@ComposiumSceneCatalog; - register scenes manually through
Composium.registerAll(...).
KSP is the primary integration path.
There are two discovery styles:
- annotate individual scene properties with
@ComposiumScene; - or annotate an object with
@ComposiumSceneCatalog.
You do not need to use both at the same time. Pick the style that matches how you want to organize scenes in your project.
Use this style when you want flat, explicit scene declarations and prefer to mark each scene directly.
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import oleginvoke.com.composium.ComposiumScene
import oleginvoke.com.composium.ComposiumScreen
import oleginvoke.com.composium.scene
enum class ButtonVariant {
Primary,
Secondary,
Danger,
}
sealed interface ButtonSize {
object Small : ButtonSize
object Medium : ButtonSize
object Large : ButtonSize
}
@ComposiumScene
internal val primaryButton by scene(
group = "Buttons/Primary",
name = "Filled",
) { contentPadding ->
val enabled: Boolean by param(true)
val text: String by param("Continue")
val variant: ButtonVariant by param(ButtonVariant.Primary)
val size: ButtonSize by param(ButtonSize.Medium)
Box(Modifier.padding(contentPadding)) {
AppButton(
text = text,
enabled = enabled,
variant = variant,
size = size,
)
}
}
@Composable
fun DebugCatalog() {
ComposiumScreen()
}Use this style when you want to keep several related scenes together inside one object and let KSP collect all non-private Scene properties from it.
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import oleginvoke.com.composium.ComposiumSceneCatalog
import oleginvoke.com.composium.ComposiumScreen
import oleginvoke.com.composium.scene
@ComposiumSceneCatalog
internal object FormScenes {
val loginDefault by scene(group = "Forms/Auth", name = "Login / default") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
LoginForm()
}
}
val loginLoading by scene(group = "Forms/Auth", name = "Login / loading") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
LoginForm(isLoading = true)
}
}
}
@Composable
fun DebugCatalog() {
ComposiumScreen()
}What KSP collects:
- every property annotated with
@ComposiumScene; - every non-private
Sceneproperty declared inside an object annotated with@ComposiumSceneCatalog.
These are two independent discovery styles, not a required pair of annotations on the same scene setup.
Manual mode uses the same scene {} API, but skips both KSP and annotations.
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import oleginvoke.com.composium.Composium
import oleginvoke.com.composium.ComposiumScreen
import oleginvoke.com.composium.scene
internal val primaryButton by scene(group = "Buttons/Primary", name = "Filled") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
AppButton(text = "Continue")
}
}
internal object FormScenes {
val loginDefault by scene(group = "Forms/Auth", name = "Login / default") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
LoginForm()
}
}
}
@Composable
fun DebugCatalog() {
LaunchedEffect(Unit) {
Composium.registerAll(
primaryButton,
FormScenes.loginDefault,
)
}
ComposiumScreen()
}Notes:
- manual registration identifies scenes by their group and name;
- scenes still use the same runtime API as KSP mode;
- annotations are not needed in manual mode.
For both automatic and manual registration, scene names must be unique within a group. Registering the same Scene instance again is safe. If a different scene uses the same group and name, the first scene is kept and Composium logs a warning with the Composium tag.
Repeated reads of a property declared with val MyScene by scene { ... } return the same Scene instance. Reading the property does not render its content.
When constructing Scene(...) directly, keep and reuse the instance for repeated registration.
Composium does not force a single scene structure.
Scenes without a group stay at the root level:
internal val typographyTokens by scene { contentPadding ->
Box(Modifier.padding(contentPadding)) {
TypographyShowcase()
}
}Use slash-separated paths to build nested groups:
internal val primaryFilled by scene(group = "Buttons/Primary/Filled") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
PrimaryFilledButton()
}
}
internal val primaryOutlined by scene(group = "Buttons/Primary/Outlined") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
PrimaryOutlinedButton()
}
}
internal val dangerFilled by scene(group = "Buttons/Danger/Filled") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
DangerButton()
}
}Group nesting is unlimited. The UI tree is derived from the group path, not from where the property is declared in code.
That means you can:
- keep scenes as a flat list in source code;
- group them with
group = "..."; - or combine that with
@ComposiumSceneCatalogobjects for code organization.
Every scene receives the full preview bounds and an explicit contentPadding. For regular content, apply it to the root so content stays clear of system bars and the default Composium top bar:
val RegularScene by scene { contentPadding ->
Box(Modifier.fillMaxSize().padding(contentPadding)) {
Content()
}
}
val FullScreenScene by scene(
tools = SceneTools.Floating,
) { contentPadding ->
Box(Modifier.fillMaxSize()) {
FullScreenBackground()
Actions(Modifier.padding(contentPadding))
}
}For edge-to-edge content, apply contentPadding only to the children that must remain unobscured. SceneTools.TopBar is the default. Use SceneTools.Floating to replace the top bar with a compact overlay containing Back, Properties, Eyedropper, and Theme controls.
The overlay starts at the top-right, below the status bar. Tap its eye button to hide or show the tools, or drag the eye to reposition them. Properties opens the controls panel; tapping it again expands the panel. Floating tools do not contribute to contentPadding or appear in eyedropper samples.
The bundled lint check reports an error when scene content does not reference its padding. For intentional full-bleed content, suppress that one issue explicitly:
@Suppress("UnusedComposiumContentPaddingParameter")
val BackgroundScene by scene {
FullScreenBackground()
}Naming the lambda argument _ still reports an error. The rule checks for an explicit reference or an explicit suppression; it does not try to prove where the padding was applied.
The main catalog screen automatically creates thumbnails for scenes. By default, Composium renders each scene on a hidden capture surface, stores the resulting image in memory, and shows that image in the scene card thumbnail area.
This keeps the catalog visual without requiring extra setup for every scene:
internal val primaryButton by scene(group = "Buttons") { contentPadding ->
Box(Modifier.padding(contentPadding)) {
PrimaryButton(text = "Continue", onClick = {})
}
}If the full scene is too heavy, too large, animated, or not representative enough for a compact card, provide a custom thumbnail. The custom thumbnail is used only for catalog thumbnail capture; opening the scene still renders the regular scene content.
internal val paymentForm by scene(
group = "Forms",
thumbnail = {
PaymentFormPreview()
},
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
PaymentForm()
}
}Use custom thumbnails for cases where the catalog should show a simplified, stable, or intentionally framed version of the component while keeping the real scene interactive and complete.
Omitting thumbnail captures the scene's content. Passing thumbnail = null explicitly disables capture for that scene and removes the entire preview area from its catalog card, without a placeholder or reserved space:
internal val paymentScreen by scene(thumbnail = null) { contentPadding ->
PaymentScreen(Modifier.padding(contentPadding))
}The card remains clickable and opens the scene normally. If a badge is provided, it appears beside the card title instead of over a preview. These defaults also apply to scenes created directly through Scene.
A nullable thumbnail variable that evaluates to null also disables capture.
Thumbnail capture runs a real composition: effects in the captured content can start requests, subscriptions, or analytics before the scene is opened. Use a custom thumbnail with static sample data, or disable capture, for scenes with such effects. A custom thumbnail is also ordinary composable content and does not automatically suppress effects.
Use badge when a scene needs an extra marker inside its catalog card. Composium renders it in the top-end corner of the thumbnail area, or beside the title when thumbnail = null; the badge content controls its own size, shape, colors, and behavior.
This is useful for lightweight per-scene metadata that should be visible before opening the scene, for example:
- readiness status: draft, ready for review, reviewed;
- QA or design-review state;
- platform or feature availability markers;
- warnings for incomplete, deprecated, or experimental components;
- selection or pinning indicators in a custom catalog flow.
The badge is regular Compose content. It can be a small colored dot, text label, icon, progress marker, or an interactive control if your catalog UX needs it.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
internal val paymentButton by scene(
group = "Buttons",
badge = {
// Reviewed
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(0xFF2E7D32)),
)
},
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
PaymentButton()
}
}Scene parameters are declared inside the scene body with delegated properties:
internal val buttonPlayground by scene(group = "Buttons") { contentPadding ->
val enabled: Boolean by param(true)
var title: String by param("Continue")
Box(Modifier.padding(contentPadding)) {
AppButton(
text = title,
enabled = enabled,
onClick = {
title = if (title == "Continue") "Saved" else "Continue"
},
)
}
}param(...) delegates can be declared as var. This lets the scene update a parameter from its own code while Composium still exposes the same value in the settings panel.
Parameter names must be unique within a scene, including parameters declared by helper composables. By default, the name matches the delegated property; pass name to set a different label:
val title: String by param("Hello", name = "Title")
val subtitle: String by param("World", name = "Subtitle")Duplicate names throw IllegalStateException identifying the conflict. This also applies when changing name dynamically. Different scenes can use the same parameter names.
| Parameter kind | UI control | Notes |
|---|---|---|
Boolean |
Switch | Automatic |
String |
Text field | Automatic |
enum |
Option chips | Values inferred automatically |
| Sealed object hierarchy | Option chips | Values inferred automatically from object instances |
| Nullable supported parameter | Checkbox + underlying control | Checkbox toggles null-state; do not add null to options manually |
| Any other type with explicit options | Option chips | Use listOf(...).toParamOptions() or explicit named values |
Important detail: you can use any type as a parameter value, but interactive selection for custom and numeric types requires explicit options unless Composium can infer them automatically.
For referential or non-static types such as Painter, prefer explicit named options.
enum class ButtonVariant {
Primary,
Secondary,
Danger,
}
sealed interface ButtonSize {
object Small : ButtonSize
object Medium : ButtonSize
object Large : ButtonSize
}
internal val buttonPlayground by scene(group = "Buttons") { contentPadding ->
val enabled: Boolean by param(true)
val variant: ButtonVariant by param(ButtonVariant.Primary)
val size: ButtonSize by param(ButtonSize.Medium)
Box(Modifier.padding(contentPadding)) {
AppButton(
enabled = enabled,
variant = variant,
size = size,
)
}
}What happens in the UI:
enabledis shown as a switch;variantis shown as chips with enum entries;sizeis shown as chips with inferred sealed object options.
For types like Int, Long, Float, Double, or custom objects, define the allowed values explicitly.
Inside scene {} you can convert raw values to List<ParamOption<T>> with toParamOptions(). If you want full control over labels, pass explicit named values instead.
If the parameter type is nullable, pass only the non-null choices. Composium handles the null state through the checkbox automatically.
internal val spacingPlayground by scene(group = "Spacing") { contentPadding ->
val elevation: Int by param(
default = 0 named "None",
options = listOf(
0 named "None",
2 named "2dp",
8 named "8dp",
16 named "16dp",
),
)
val alpha: Float by param(
default = 1f,
options = listOf(0.25f, 0.5f, 0.75f, 1f).toParamOptions(),
)
Box(Modifier.padding(contentPadding)) {
ExampleCard(
elevation = elevation,
alpha = alpha,
)
}
}You can override how options are shown in the controls UI.
default can also be passed as a named option with infix syntax:
val mode by param(
default = DisplayMode.Grid named "Grid",
options = listOf(
DisplayMode.Grid named "Grid",
DisplayMode.List named "List",
),
)Using a name mapper:
val role by param(
default = UserRole.Member,
options = listOf(
UserRole.Admin,
UserRole.Member,
UserRole.Guest,
).toParamOptions { role ->
when (role) {
UserRole.Admin -> "Administrator"
UserRole.Member -> "Member"
UserRole.Guest -> "Guest"
}
},
)Using explicit named values:
val alignment by param(
default = AlignmentMode.Center,
options = listOf(
AlignmentMode.Start named "Start",
AlignmentMode.Center named "Center",
AlignmentMode.End named "End",
),
)Using a named default without explicit options:
val leadingIcon: Painter? by param(
painterResource(R.drawable.solid_attention) named "Attention",
)For referential values such as Painter, names act as the stable identity for explicit option chips. In these cases, name every option and name the default too when it is declared independently from the option list.
If explicit option names collide inside the same parameter, Composium will append numeric suffixes automatically until every name becomes unique.
Nullable parameters get an extra checkbox that controls whether the value is currently null.
internal val cardPlayground by scene(group = "Cards") { contentPadding ->
val maxLines: Int? by param(
default = null,
options = listOf(
1 named "1 line",
2 named "2 lines",
3 named "3 lines",
),
)
val leadingIcon: Painter? by param(
painterResource(R.drawable.solid_attention) named "Attention",
)
val subtitle: String? by param(null)
Box(Modifier.padding(contentPadding)) {
ExampleCard(
maxLines = maxLines,
leadingIcon = leadingIcon,
subtitle = subtitle,
)
}
}What happens in the UI:
- nullable parameters get a checkbox in the control header;
- unchecked means the parameter is currently
null; - checked restores the value and shows its regular control.
For nullable parameters with explicit options, do not include null in the list yourself. Pass only non-null values and let Composium manage the null-state toggle.
If you declare a nullable parameter with param(options = ...) and do not provide an explicit default, Composium uses the first option from the list as the initial value (including null if it is first).
For automatically inferred sealed options, you can still override their display order:
By default, auto-inferred sealed options are sorted by their generated names using natural ordering (for example, r2, r10, r100).
val size: ButtonSize by param(ButtonSize.Medium) { inferred ->
inferred.reversed()
}A scene can be rendered from a regular Android Studio @Preview through the
RenderPreview extension:
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import oleginvoke.com.composium.RenderPreview
@Preview(showBackground = true, widthDp = 420, heightDp = 720)
@Composable
private fun PrimaryButtonPreview() {
PrimaryButtonScene.RenderPreview()
}Every preview owns an independent scene scope and uses the scene's default parameter values.
If several scenes need the same preview chrome, create a small project-local helper around scene(...).
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import oleginvoke.com.composium.SceneDelegate
import oleginvoke.com.composium.SceneScope
import oleginvoke.com.composium.scene
fun sceneWithFrame(
group: String? = null,
name: String? = null,
content: @Composable SceneScope.() -> Unit,
): SceneDelegate = scene(
group = group,
name = name,
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.background(Color(0xFFF7F7F7))
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
content()
}
}This wrapper consumes contentPadding in its own frame before invoking the caller's content, like the sample app's sceneWithDecorator. Alternatively, a wrapper can expose and forward the padding-aware content signature directly:
fun sceneForwardingPadding(
group: String? = null,
name: String? = null,
content: @Composable SceneScope.(PaddingValues) -> Unit,
): SceneDelegate = scene(
group = group,
name = name,
content = content,
)Wrappers that expose SceneScope.(PaddingValues) -> Unit are checked by the same bundled lint detector, so their callers must apply or forward the argument, or use the intentional full-bleed suppression.
Use that helper for scenes that should share the same frame:
val PrimaryButtonScene by sceneWithFrame(group = "Buttons") {
PrimaryButton(text = "Save", onClick = {})
}ComposiumScreen() accounts for system bars and display cutouts by default. It keeps the background
edge-to-edge while positioning its own UI and providing the appropriate contentPadding to scenes.
Floating tools do not contribute to scene padding. Keyboard handling is separate from these default insets.
When embedding Composium inside a Scaffold, apply and consume the outer padding:
Scaffold { innerPadding ->
ComposiumScreen(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding),
)
}Composium excludes insets already consumed by its parents. A plain Modifier.padding(...) does not
mark insets as consumed. If your container handles all system padding itself, disable Composium's
insets explicitly:
ComposiumScreen(
modifier = Modifier.padding(innerPadding),
contentWindowInsets = WindowInsets(0),
)The default is available as ComposiumDefaults.contentWindowInsets. You can also supply custom
WindowInsets to control which sides and inset types Composium handles.
This configures content placement; it does not enable edge-to-edge on the activity's window.
Composium supports two theme ownership models.
If you just call ComposiumScreen(), Composium manages its own dark theme state and exposes a built-in toggle in the UI.
@Composable
fun DebugCatalog() {
ComposiumScreen()
}If your app already owns theme state, pass it in and keep Composium synchronized with your theme system:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import oleginvoke.com.composium.ComposiumScreen
@Composable
fun DebugCatalog() {
var isDarkTheme by remember { mutableStateOf(false) }
AppTheme(darkTheme = isDarkTheme) {
ComposiumScreen(
isDarkTheme = isDarkTheme,
onThemeChange = { isDarkTheme = it },
)
}
}This mode is useful when:
- your app already has a single source of truth for theme;
- edge-to-edge setup depends on dark mode;
- you want Composium to follow the same theme logic as the rest of the app.
Inside a scene, Composium also provides runtime preview controls for environment simulation:
- dark theme;
- display size;
- font size;
- RTL layout.
These controls are useful for quickly checking how a component behaves under different system conditions without leaving the scene browser.
Examples of what you can validate:
- typography overflow with larger font size;
- density-sensitive layouts with different display size multipliers;
- dark theme colors;
- mirrored layout issues in RTL.
Keep a single app-internal catalog with button, text field, card, sheet, and token scenes.
Put scene definitions close to feature components and expose real states like loading, error, empty, success, and disabled.
Give QA a stable in-app surface where they can switch component states without hidden debug menus.
Use scenes as a fast sandbox for composing UI states that would be awkward to wire into production navigation.
If you want to fix a bug, improve the API, or extend the library with new functionality, contributions are welcome.
Composium is a runtime scene browser for Compose that aims to stay out of your way:
- use KSP when you want automatic discovery;
- skip KSP when you want manual registration;
- keep scenes flat or organize them into deep nested groups;
- use automatic controls where possible and explicit options where needed;
- choose the default top bar or compact floating scene tools and apply
contentPaddingwhere content must remain unobscured; - let Composium own theme state or plug it into your own;
- create scene helper wrappers for shared preview chrome;
- inspect components under different preview system settings.



