One afternoon I was updating Compose previews. Again. Fourth time that day. I wasn't even writing them myself. I'd tell the LLM "you missed updating the preview ๐ ", which kind of gave the game away.
A preview is a function, an annotation, and some dummy data. If an LLM can write it from the signature alone, so can the compiler.
So I built Pose ๐๏ธ

One annotation. Every state. ๐ฌ#
sealed interface SearchState {
data object Loading : SearchState
data object NoResults : SearchState
data class Results(val query: String, val hitCount: Int) : SearchState
}
@Pose
@Composable
fun SearchContent(state: SearchState, onRetry: () -> Unit) { /* โฆ */ }
That's the whole diff. โ๏ธ One line. Next build, build/generated/ has this:
@Composable
@Preview(name = "Light", uiMode = 16, showBackground = true)
@Preview(name = "Dark", uiMode = 32, showBackground = true)
internal fun SearchContent__Preview_Results() {
CompositionLocalProvider(LocalInspectionMode provides true) {
AppPose.Theme {
SearchContent(
state = SearchState.Results(query = "Query", hitCount = 0),
onRetry = {},
)
}
}
}
// โฆplus SearchContent__Preview_Loading and SearchContent__Preview_NoResults
Pose saw a sealed type and fanned it out. One preview per subtype, each named after it, in light and dark. ๐จ
Now the good part:
| You do this | Pose does this |
|---|---|
| Add a 4th state | A 4th preview appears โ |
Rename onRetry |
Regenerates, nothing breaks ๐ |
| Delete the composable | Preview vanishes ๐๏ธ |
| Nothing | They're still correct โ |
It was never yours to maintain.
Then it gets silly ๐ธ#
Paparazzi, Roborazzi and Google's screenshot plugin all scan for @Preview. Generated previews compile down to exactly the same thing as handwritten ones.
So they get picked up. Automatically:
1 annotation โ 3 previews โ 6 renders โ 6 screenshot tests
Zero test code. You annotated a composable and got visual regression coverage of every state in both themes.
And it can't drift. Previews regenerate every build, so a golden can never quietly test last month's UI. ๐
๐ต๏ธ One reason this actually works: Studio sets
LocalInspectionMode, snapshot runners don't. A composable that swaps a network image for a placeholder takes its production path in a screenshot test and renders blank. Pose provides the local itself, so both agree.
Whole module, one line ๐#
@PoseSetup(generateForAllPublicComposables = true)
Every public composable in the module. Previewed. Screenshot-tested. Skips anything with a handwritten @Preview, anything marked @PoseIgnore, and wrapper-shaped things like themes. Awkward composables become warnings, never build failures.
Your data, everywhere you use it ๐งช#
Placeholders are fine until you need the ugly cases. Drop this on the type:
companion object {
val previewSamples: Sequence<Track> get() = sequenceOf(
Track(title = "Short"),
Track(title = "A title long enough to wrap onto two lines"),
Track(title = "", explicit = true),
)
}
Now every composable taking a Track renders all three. Write it once, every screen in the app benefits. Wired through @PreviewParameter for you. โจ
Don't own the type?#
@Pose(providers = [TrackSamples::class]), matched by generic type.
๐ก Note the
get() = sequenceOf(...)builds its arguments eagerly, aSequenceis lazy about iteration, not construction. A plainvalwould construct every sample in production, for data only the preview panel reads.
Setup โก#
Two blocks. That's it.
dependencies {
implementation("io.github.akshaychordiya.pose:annotations:<latest>")
kspDebug("io.github.akshaychordiya.pose:processor:<latest>")
}
@PoseSetup
internal object AppPose : PoseConfig {
@Composable
override fun Theme(content: @Composable () -> Unit) {
AppTheme { content() }
}
}
No strings anywhere. Pose never learns your theme's name, it emits AppPose.Theme { โฆ } and lets the compiler resolve it. Rename AppTheme and the IDE fixes this file too. โ
Want a preview matrix instead of light/dark? @PoseSetup(previews = [AppPreviews::class]). Font scale, RTL, tablet, all in one place, one edit for the whole module. ๐๏ธ
Works on Android and Compose Multiplatform ๐
"Can't I just useโฆ?" ๐ค#
You might be thinking why can't I just use LLM or IDE plugins to generate previews? Well you can!
| Writes into | Survives a rename | |
|---|---|---|
| Studio's Generate Preview | your source | โ you own it now |
| IDE plugins | your source | โ |
| Asking an LLM | your source | โ |
| Pose | build/generated/ |
โ |
They all solve authoring. Authoring was never hard, the first preview takes thirty seconds. The cost is everything after. You don't want a tool that writes previews. You want to not own them. ๐ฏ
There's an IDE plugin too ๐จ#
Generated code lives in build/, so you won't automatically see the previews in your source. So the IntelliJ plugin shows a gutter icon to your Composables which takes you to the previews. The companion plugin adds one. Click it, land on the generated preview, with a picker when a sealed type fanned out.
It is entirely optional just to make it easier to connect the autogenerated previews and your @Composable. The processor works fine alone.
![]()
Try it ๐#
Ready to strike a pose? ๐ธ
Check out Pose on GitHub and stop manually maintaining your previews today! โจ