Auto-generate Jetpack Compose @Preview functions from your @Composable code at build-time, deterministic, offline, no LLM.
Your composable strikes a Pose for the preview panel. Annotate, build, done. β¨
@Composable
@Pose
fun LoginContent(state: LoginUiState, onSubmit: () -> Unit) { /* β¦ */ }Every build regenerates into build/generated/ksp/debug/kotlin/:
@PreviewLightDark @Composable
internal fun LoginContent__Preview() {
AppTheme {
LoginContent(
state = LoginUiState(email = "Email", password = "Password", isSubmitting = false, error = null),
onSubmit = {},
)
}
}- Never checked in - regenerates on every build π
- Always in sync with the composable signature π―
- Zero maintenance π§
Compose's preview ecosystem is great at consuming previews (Showkase, Paparazzi, Roborazzi, Google's screenshot-testing plugin) but has no build-time story for generating them.
- Gemini's Generate Preview and IDE plugins write source you have to maintain π€
- Hand-written previews rot the moment the composable signature changes π
- Pose never touches your source. Files land in
build/generated/, always fresh βοΈ
plugins { id("com.google.devtools.ksp") }
dependencies {
implementation("io.github.akshaychordiya.pose:annotations:0.6.3")
kspDebug("io.github.akshaychordiya.pose:processor:0.6.3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
}
Then declare one config object per module - this is where your theme goes:
@PoseSetup
internal object AppPose : PoseConfig {
@Composable
override fun Theme(content: @Composable () -> Unit) {
AppTheme { content() } // β ordinary Kotlin: type-checked, rename-safe
}
}Pose never learns your theme's name - it emits AppPose.Theme { β¦ } and lets the compiler resolve the rest. Rename AppTheme and the IDE refactors this file with everything else.
Requirements
- Kotlin 2.0+, KSP 2.x
- AGP 8.2+, Compose BOM 2024.02+
- JDK 17+
Tested on Kotlin 2.4.x Β· KSP 2.3.x Β· AGP 9.2.x.
No config object? Pose still works - you just get no theme wrapper. Everything on @PoseSetup has a sensible default.
Annotate any composable:
@Pose
@Composable
fun MyScreen(state: UiState, onEvent: (Event) -> Unit) { /* β¦ */ }Install the companion Pose IntelliJ plugin - either from the Marketplace page directly, or from inside your IDE:
Settings β Plugins β Marketplace- Search Pose - Auto generate Compose Previews
- Click Install
- Restart your IDE
You then get:
- ποΈ Gutter icon next to every composable Pose generates a preview for both explicit
@Poseand bulk-mode composables without one - π±οΈ Click to jump into the matching
<Composable>__Preview*function inside the generated file - π― Popup chooser for sealed fan-outs - pick
_Loading/_Success/_Errorand land there directly
Requires Android Studio Ladybug (2024.2) or newer. K1 and K2 modes both supported. Optional but recommended - the KSP processor works standalone; the plugin just removes the need to open build/generated/ by hand.
sealed interface HomeState {
data object Loading : HomeState
data class Success(val user: User) : HomeState
data class Error(val message: String) : HomeState
}
@Pose
@Composable
fun HomeContent(state: HomeState) { /* β¦ */ }Emits three named preview functions:
HomeContent__Preview_LoadingHomeContent__Preview_SuccessHomeContent__Preview_Error
Studio labels each render by its subtype name - the preview panel reads cleanly at a glance.
For empty / error / long-text / edge-case variants, expose a sequence on the type's companion:
data class LoginUiState(...) {
companion object {
val previewSamples: Sequence<LoginUiState> get() = sequenceOf(
LoginUiState(email = "", β¦),
LoginUiState(isSubmitting = true, β¦),
LoginUiState(error = "Invalid", β¦),
)
}
}Pose wires it through @PreviewParameter automatically. Write once, benefit everywhere - every composable that takes LoginUiState now shows all variants.
Put a PreviewParameterProvider in src/debug/kotlin and reference it with @Pose(providers = [...]) instead π
Don't own the type? Want composable-scoped sample data without polluting the model? Attach a PreviewParameterProvider<T> at the annotation:
class ArticleSamples : PreviewParameterProvider<Article> {
override val values = sequenceOf(
Article(title = "Hello world", body = "First paragraph"),
Article(title = "Longer post", body = "Longer body with more text"),
)
}
@Pose(providers = [ArticleSamples::class])
@Composable
fun ArticleCard(
article: Article, // β auto-matched to ArticleSamples by generic type
onOpen: () -> Unit,
) { /* β¦ */ }Pose walks each entry's PreviewParameterProvider<T> supertype, extracts T, and matches it against the composable's parameter types. Emits ArticleSamples().values.first() inline. Providers stay scoped to this composable - they never leak into others.
Two parameters of the same type? Annotate them individually with @PoseSample:
@Pose
@Composable
fun ArticleComparison(
@PoseSample(ArticleSamples::class) left: Article,
@PoseSample(FeaturedArticleSamples::class) right: Article,
) { /* β¦ */ }The binding lives on the parameter, so renaming left carries it along - nothing to fall out of sync. @PoseSample wins over providers when both would apply.
Bundle every dimension your team cares about into one annotation:
@Preview(name = "Light", uiMode = UI_MODE_NIGHT_NO)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES)
@Preview(name = "RTL", locale = "ar")
@Preview(name = "Font 1.5Γ", fontScale = 1.5f)
@Preview(name = "Landscape", widthDp = 640, heightDp = 360)
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.ANNOTATION_CLASS)
annotation class AppPreviews
@Composable @Pose(previews = [AppPreviews::class])
fun MyChip(...) { /* β¦ */ }Adding a new dimension (dynamic-color, foldables, tablet-size) is a one-line edit that ripples through every generated preview. Default is Pose's own light + dark pair with showBackground = true.
Generated previews are wrapped in CompositionLocalProvider(LocalInspectionMode provides true) automatically:
internal fun PhotoViewer__Preview() {
CompositionLocalProvider(LocalInspectionMode provides true) {
AppTheme {
PhotoViewer(url = "Url")
}
}
}Why this matters - Android Studio's preview renderer sets LocalInspectionMode for you, but snapshot runners (Paparazzi, Roborazzi, com.android.compose.screenshot) leave it false. A composable that branches on it:
@Composable
fun PhotoViewer(url: String) {
if (LocalInspectionMode.current) {
Image(painterResource(R.drawable.placeholder), null) // static fallback
} else {
AsyncImage(model = url, contentDescription = null) // real network call
}
}β¦would render correctly in Studio but fall through to the production path in a snapshot test - attempting a network fetch with a dummy URL and producing a blank image. Pose provides the local so both environments agree.
Opt out with @PoseSetup(provideInspectionMode = false).
Need other CompositionLocals? Override Wrapper on your config object β fake image loaders, no-op analytics, locale providers:
@PoseSetup
internal object AppPose : PoseConfig {
@Composable
override fun Theme(content: @Composable () -> Unit) {
AppTheme { content() }
}
@Composable
override fun Wrapper(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalImageLoader provides fakeImageLoader(),
LocalAnalytics provides NoOpAnalytics,
) { content() }
}
}Nesting, outermost first: inspection-mode provider β your wrapper β theme β target composable. Your wrapper sits inside Pose's provider deliberately - innermost CompositionLocalProvider wins, so you keep the final say over any local Pose also sets.
Every @Pose composable becomes a free visual regression test with Paparazzi, Roborazzi, or Google's com.android.compose.screenshot - with zero extra test code.
Google's screenshot testing plugin
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.sources.screenshotTest?.addStaticSourceDirectory(
layout.buildDirectory.dir("generated/ksp/debug/kotlin").get().asFile.path
)
}
}Paparazzi / Roborazzi (via ComposablePreviewScanner)
class PoseSnapshotTest {
@get:Rule val paparazzi = Paparazzi()
@TestParameter lateinit var preview: ComposablePreview<AndroidPreviewInfo>
@Test fun snapshot() = paparazzi.snapshot { preview() }
companion object {
@JvmStatic fun previews() =
AndroidComposablePreviewScanner().scanPackageTrees("com.example").getPreviews()
}
}The lifecycle takes care of itself:
- Add a composable - next CI run adds a golden to review β
- Rename it - the golden name tracks automatically π€
- Delete it - the golden disappears ποΈ
Visual regression coverage stops being separate work. It's the annotation you already added for previews.
Per parameter, first match wins (top-down):
| Tier | Rule |
|---|---|
| Explicit | @PoseSample on the parameter, else a @Pose(providers = [...]) generic-type match |
| T0 | Parameter has a default value - omit the argument |
| T1 | Well-known FQN (Compose value classes, Flow, StateFlow, java.time, Uri, Result<T>, β¦) - inline expression |
| T2 | Structural synthesis (primitives, enums, data classes, sealed, value classes, function types, collections) |
| Refuse | No strategy - emits a PG-xxx diagnostic and skips |
Deterministic - never Random, never clock, never classpath-iteration order.
Pose emits actionable PG-xxx diagnostics for:
- Refuse-list parameter types:
ViewModel, Hilt,SavedStateHandle,NavController,NavBackStackEntry,Context/Activity/Fragment,Bitmap,Channel,CoroutineScope,AsyncImagePainter. Hoist state to aContent(state, onEvent)variant. - Generic type parameters or context receivers on the composable.
privatevisibility, or member composables must be top-level or in anobject.
Every refusal message names the composable, gives a concrete fix, and links to the refusal catalog.
For teams that want previews everywhere without annotating each composable, flip one flag on the config object:
@PoseSetup(generateForAllPublicComposables = true)
internal object AppPose : PoseConfig { /* β¦ */ }Every public top-level @Composable fun β¦ : Unit in the module now gets a generated preview. Bulk mode implicitly sets pose.strict = false - a single un-fakeable composable turns into a warning-and-skip instead of failing the build.
Opt individual composables back out with @PoseIgnore:
@PoseIgnore
@Composable
fun DebugOverlay(state: DebugState) { /* β¦ */ } // public but noisy in the preview panelPose also skips any composable that already carries @Preview, and honors explicit @Pose(...) arguments (name / wrapInTheme / previews / providers) when both are present.
Everything lives on the module's @PoseSetup object:
| Setting | Default | Behavior |
|---|---|---|
Theme override |
passthrough | Wraps every generated preview - your theme goes here |
Wrapper override |
passthrough | Supplies CompositionLocals (fake image loaders, no-op analytics) |
generateForAllPublicComposables |
false |
Bulk opt-in. Implies non-strict diagnostics |
previews |
unset | Multipreview annotations for the whole module |
provideInspectionMode |
true |
Wrap in CompositionLocalProvider(LocalInspectionMode provides true) |
maxPreviewsPerComposable |
8 |
Cap on previews per composable |
maxDepth |
8 |
Cap on structural-synthesis recursion |
collectionSize |
2 |
Elements emitted for List / Set |
showBackground |
true |
showBackground = true on the default light/dark pair. Ignored when previews is set |
Only two, both build-behaviour knobs you might want to differ between a local build and CI. Everything that shapes previews lives on @PoseSetup above, where the compiler checks it.
| Option | Default | Behavior |
|---|---|---|
pose.strict |
true |
false demotes refusals to warnings instead of failing the build |
pose.verboseSkips |
false |
Log every skip decision |
Anything else starting with pose. warns via PG023 with a "did you mean" suggestion β including options removed in 0.6.0, so an old config tells you rather than silently doing nothing.
- Android - first-class, tested end-to-end via the sample-app.
- Kotlin Multiplatform / Compose Multiplatform - verified working on real CMP + KMP projects.
androidx.compose.ui.tooling.preview.Previewunified across Android and CMP, so Pose's emission works on both. Wire the processor into the appropriateksp<Target>Mainconfiguration in your module'sbuild.gradle.kts.
- Body-level analysis - Pose only sees signatures. A composable that internally calls
hiltViewModel()or reads aLocalContextmay crash at preview render. Hand-write a@Previewfor those (Pose skips), or refactor to stateless. - Other non-goals - ViewModel/Hilt params (refused by design - split into
Screen(vm) / ScreenContent(state, onEvent)per PG003), runtime fake-data libs, context params, default-expression forwarding (KSP #268).
See CONTRIBUTING.md for project layout, the processor pipeline diagram, and how to add types or refusal rules.
Also see the refusal catalog and the changelog.
Apache 2.0. See LICENSE.
