Killing Compose @Preview boilerplate with one annotation

To see a Compose screen properly, you need it on every device, in both themes, in every state: empty, loading, error, long text. Writing that by hand takes dozens of @Preview functions per screen, and Android Studio slows down trying to render them all.

compose-auto-preview is an open-source library I maintain that replaces all of it with one annotation. It gives you two things:

  • In Android Studio, a light preview pane: every device and theme, first state only.
  • From Gradle, the full matrix as PNGs, plus an HTML report that draws your app as a navigation graph.
compose-auto-preview report: ten app screens as thumbnails, connected by navigation arrows from the entry point
The report for a 10-screen sample app. Open the live demo: 169 renders, no install needed.

The problem: previews don't scale

A well-previewed screen in Compose today looks like this:

class SettingsStateProvider : PreviewParameterProvider<SettingsState> {
    override val values = sequenceOf(
        SettingsState(),
        SettingsState(notificationsEnabled = false),
        SettingsState(username = "max"),
    )
}

@Preview(name = "Phone · Light", device = Devices.PIXEL_7)
@Preview(name = "Phone · Dark",  device = Devices.PIXEL_7, uiMode = UI_MODE_NIGHT_YES)
@Preview(name = "Tablet · Light", device = Devices.PIXEL_TABLET)
@Preview(name = "Tablet · Dark",  device = Devices.PIXEL_TABLET, uiMode = UI_MODE_NIGHT_YES)
@Composable
internal fun SettingsScreenPreview(
    @PreviewParameter(SettingsStateProvider::class) state: SettingsState,
) = SettingsScreen(state)

That's the small version. It has two costs.

You write and maintain it by hand. Every screen needs its own PreviewParameterProvider and its own stack of @Preview lines. Add a foldable, add two states, multiply by 30 screens. The name = "..." strings drift the first time someone copy-pastes.

Android Studio pays for every cell. Studio keeps a render session for each preview cell in the open file, so memory grows with the cell count. A few screens at device × theme × state can take gigabytes. The pane stops repainting, and you reach for Invalidate Caches and Restart.

Neither is a Compose bug. The preview API just wasn't designed for a matrix of states across a whole app.

The fix: describe the matrix once

List your states in a plain Kotlin object:

object SettingsSamples {
    val Default          = SettingsState()
    val NotificationsOff = SettingsState(notificationsEnabled = false)
    val Filled           = SettingsState(username = "max")
}

Then write one preview function:

@AutoPreview(
    samplesFrom = SettingsSamples::class,
    devices = [Device.Phone, Device.Tablet],
    themes  = [Theme.Light, Theme.Dark],
)
@SettingsScreenAutoPreviews
@Composable
internal fun SettingsScreenPreview(
    @PreviewParameter(SettingsScreenPreviewSamplesProvider::class) state: SettingsState,
) = SettingsScreen(state)

On the first build, KSP generates what you used to write by hand:

  1. SettingsScreenPreviewSamplesProvider, a PreviewParameterProvider with your samples in declaration order.
  2. @SettingsScreenAutoPreviews, a multi-preview annotation holding the device × theme grid.

Both names are red in the editor until that first build. Type them anyway: that's the only friction worth knowing up front.

Two places, two jobs

The same annotation renders in two places, each tuned for what you do there:

Where What renders Why
Android Studio devices × themes, first state only Keeps the pane responsive while you edit
./gradlew autoPreview devices × themes × states as PNGs + HTML report Renders the full matrix outside the IDE, in a separate JVM

With 3 samples in the example above, Studio shows 4 cells and the report holds 12. You edit against a light pane and review everything in one place.

The Gradle side renders with Robolectric, so there's no emulator and no device. The task is incremental: if the code didn't change, nothing re-renders.

The report: your app on one page

Running ./gradlew :app:autoPreview builds a static HTML report and opens it in your browser. It has three views.

The app graph. Mark your start screen with entryPoint and declare edges with navigatesTo:

@AutoPreview(samplesFrom = OnboardingSamples::class, entryPoint = true, navigatesTo = ["SettingsScreen"])
@AutoPreview(samplesFrom = SettingsSamples::class, navigatesTo = ["ConfirmDialog"])

The report lays the screens out from the entry point along those edges, like the graph at the top of this post. Screens the entry point can't reach are drawn dashed on the outer ring, and an unknown screen id is a build warning. You get a navigation map that stays in sync with the code, because the code produces it.

A page per screen. Every state in light and dark, one tab per device:

One screen in the report: five states of a habit tracker's Today screen, each rendered in light and dark theme on a tablet
Every state of one screen, light and dark, per device. A full-screen viewer steps through them: ←/→ for states, T toggles theme, / searches.

This is where regressions show up. A clipped label in the long-text state or an unreadable color in dark mode is hard to miss when every state sits side by side.

A list of every screen, with how many hops each one is from the entry point, where it's reached from, where it goes, and which devices it targets:

Report list view: a table of ten screens with hops from the entry point, incoming and outgoing navigation, state count and target devices
The list view. The Wear screen is flagged as unreachable from the entry point.

The report is plain HTML, CSS and JS: no server and no build step. It opens from disk, so you can publish it with GitHub Pages as a living design review. The demo in this post is exactly that.

Setup in two minutes

1. Apply the plugin next to KSP. It adds the annotations, the processor and Robolectric for you:

plugins {
    alias(libs.plugins.ksp)
    id("app.mashlab.compose-auto-preview") version "4.0.0"
}

2. Add a samples object and one preview function, as shown above. The function must be internal or public.

3. Render:

./gradlew :app:autoPreview

Requirements: Kotlin 2.0+, KSP 2.0+, Jetpack Compose or Compose Multiplatform 1.7+, minSdk 28, JVM 11. The artifacts are on Maven Central.

Upgrading from 3.x? The group moved from io.github.drunkendealer to app.mashlab, and the package moved to app.mashlab.autopreview. Drop the two old dependencies, apply the plugin, and update your imports.

Share the config across your app

Most apps want the same devices and themes on every screen. Hoist them into a wrapper annotation once:

@AutoPreview(
    samplesFrom = Unit::class, // placeholder, overridden at use site
    devices = [Device.Phone, Device.Tablet, Device.Foldable, Device.Desktop],
    themes  = [Theme.Light, Theme.Dark],
)
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class AppPreview(val samplesFrom: KClass<*>)

Now each screen declares only what's specific to it:

@AppPreview(samplesFrom = SettingsSamples::class)
@SettingsScreenAutoPreviews
@Composable
internal fun SettingsScreenPreview(
    @PreviewParameter(SettingsScreenPreviewSamplesProvider::class) state: SettingsState,
) = SettingsScreen(state)

That's the part that scales: 30 screens × 3 lines instead of 30 screens × 30 lines. Available devices are Phone, Tablet, Foldable, Desktop, Tv and Wear.

How it compares to screenshot testing

Paparazzi, Roborazzi and Google's Compose Preview Screenshot Testing are testing tools. They record golden images and fail the build when pixels change. You still write each preview or test yourself.

compose-auto-preview handles the step before that. It generates the previews and shows the whole app in one report. It doesn't diff images, so use it alongside those tools, not instead of them.

Good to know

  • Dialogs and bottom sheets render in a separate window. Wrap them in Box(Modifier.fillMaxSize()) so the underlay has a canvas.
  • Kotlin Multiplatform: states and samples can live in commonMain. The @AutoPreview function goes in androidMain, where KSP runs.
  • Locale is one value per preview. You rarely compare languages side by side, so rendering every locale would multiply the matrix for little gain. Change the locale field when you want to check another language.
  • Report vs Studio: Robolectric and Studio's renderer are close but not always pixel-identical. Infinite animations freeze on their first frame.

When not to use it

With three screens and one device, stacked @Preview annotations are fine. This library pays off when:

  • you have more than a handful of screens,
  • you preview on several devices or in both themes,
  • Studio's preview pane has already started to lag,
  • you want a map of your app that can't go out of date.

The short version

One samples object and one annotation per screen. Studio stays fast with the first state, and ./gradlew autoPreview renders everything else into a report that doubles as a map of your app.

Try it on one screen: compose-auto-preview on GitHub, or browse the live demo report first.

FAQ

Why does Android Studio slow down with many Compose previews?

Studio keeps a render session for every preview cell in the open file, so memory grows with the number of cells. Stacking devices, themes and states multiplies that count, and a few screens can take gigabytes. compose-auto-preview shows only the first state in Studio and renders the rest outside the IDE.

How do I preview a Compose screen in every state without writing many @Preview functions?

List the states in a Kotlin object and annotate one preview function with @AutoPreview. KSP generates the PreviewParameterProvider and a multi-preview annotation for every device and theme, and ./gradlew autoPreview renders the full device × theme × state matrix.

Is compose-auto-preview a screenshot testing tool?

No. Paparazzi, Roborazzi and Compose Preview Screenshot Testing record golden images and fail the build on pixel changes. compose-auto-preview generates the previews and renders them into a browsable report, but it doesn't diff images, so it works alongside those tools.

Does compose-auto-preview work with Compose Multiplatform?

Yes. States and samples can live in commonMain. The @AutoPreview function goes in androidMain, where KSP runs and where the previews render.

More from the blog

← All posts