โ† All articlesIntroducing Pose library ๐Ÿ“ธ
14 September 2026
Pose Icon Thumbnail

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 ๐Ÿž๏ธ

Pose generating a Compose preview at build time


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, a Sequence is lazy about iteration, not construction. A plain val would 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. โœ…

๐Ÿ“Œ Check the latest version

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.

Gutter icon in IntelliJ / Android Studio


Try it ๐Ÿš€#

Ready to strike a pose? ๐Ÿ“ธ

Check out Pose on GitHub and stop manually maintaining your previews today! โœจ