@Preview is Android-only, and your shared UI has no preview at all
The annotation lives in an Android artifact, so commonMain cannot use it — and that shapes how you write composables.
TL;DR
Compose Multiplatform shares @Composable. It does not share @Preview — the Android annotation
lives in androidx.compose.ui:ui-tooling-preview, an Android artifact, and the IDE renderer that
draws it is Android-specific.
So the UI code you moved into commonMain for the sharing benefit loses its previews, which is a
real and annoying cost. The useful part is what the workaround forces: a stateless composable
taking a state object, with the stateful wrapper separate. That is the structure previews wanted
all along, and it is also what makes the UI screenshot-testable on the desktop target.
Design for state-in, events-out. Then the Android preview, the desktop preview, and the screenshot test all consume the same composable.
The case this post is about
A screen moved from the Android app module into the shared module. Before the move, it previewed fine:
// androidApp — before
@Composable
fun ReadingListScreen(viewModel: ReadingViewModel = koinViewModel()) {
val state by viewModel.state.collectAsState()
Column { … }
}
@Preview(showBackground = true)
@Composable
private fun ReadingListPreview() {
ReadingListScreen() // worked, sort of — see below
}
Moved verbatim into commonMain, and the annotation does not resolve:
e: Unresolved reference: Preview
e: Unresolved reference: androidx.compose.ui.tooling.preview
Worth noting the "sort of" above: that preview was already poor. It constructed a real ViewModel through DI, so the render depended on whatever the DI graph produced — usually an empty state, and occasionally a crash in the IDE renderer. The move exposed a problem that predated it.
The symptom
Three distinct losses, and one of them is not what people expect.
No preview in the IDE for shared composables. The annotation does not exist in commonMain,
so there is nothing to render. For a team used to iterating on layout in the preview pane, this is
the one that hurts daily.
@Preview in androidMain cannot see commonMain private composables. The obvious workaround
— put the previews in the Android source set — only reaches internal or public composables,
so the small private pieces you most want to preview are unreachable.
And the loss nobody mentions: no design review artifact. Previews were doubling as the thing a designer looked at. After the move there was no rendered image of the shared UI anywhere, on any platform, which is a review-process gap rather than a tooling one.
Why the obvious fix failed
"Add ui-tooling-preview to commonMain." It has no multiplatform artifact, so resolution
fails for the native and wasm targets. This is the same class as any Android-only dependency in
shared code.
"Declare expect annotation class Preview." Clever, compiles, and useless: the IDE's preview
renderer looks for the specific Android annotation by its fully-qualified name. An
expect/actual alias satisfies the compiler and the tooling never sees it. I spent a while on
this one before understanding that the annotation is not the mechanism — the renderer is.
"Keep the UI in androidMain so previews work." Gives up the sharing that motivated the move.
It also leads to the worst outcome: two implementations of the same screen, one shared and one
Android, drifting.
"Use the desktop app as the preview." Closer to right, and running the whole app to see one component is a slow loop. Useful as a fallback, not as the iteration tool.
The actual mechanism
Three separate things, and they are usually conflated into one word.
@Composable is multiplatform. The compiler plugin and the runtime are, which is why the UI
code itself moves cleanly.
@Preview is an Android annotation. It ships in an Android library and carries Android
configuration — device, uiMode, fontScale, showSystemUi. Those parameters describe an
Android rendering environment, which is a real reason the annotation is not portable rather than an
oversight.
The renderer is IDE tooling, bound to the Android rendering stack. The preview pane runs your composable against a stub Android runtime (the same mechanism Layoutlib provides). There is no equivalent host for iOS or wasm, so there is nothing to render into.
Compose Multiplatform does ship a desktop @Preview in org.jetbrains.compose.ui.tooling.preview
— available in desktop source sets, not commonMain, and with its own tooling support. So
there are two preview annotations, and neither lives where the shared code does.

@Composableshares because it is a compiler feature.@Previewdoes not because it is a tooling feature bound to one platform's renderer. The split between portable language features and non-portable tooling is the thing to expect at every boundary.
And the boundary worth stating before the fix, because the fix does not remove it: an Android preview of a shared composable tells you how it renders on Android. Default text styles, font metrics, density rounding and the platform ripple are not identical on iOS, so a layout that is balanced in the preview pane can still clip on a phone.
The practical shape of that: a two-line label that fits at Android's default bodyMedium metrics
and wraps to three on iOS — which changes the row height, which changes what fits on screen. The
preview is a fast check on structure and a weak one on fit, and the gap widens exactly where
text is near a boundary.
So previews answer "did I wire this correctly" on every platform, and "does this look right" on one. Treat the second answer as provisional until something has rendered it on the target.
The fix
Split stateless from stateful. Then previews live in the platform source sets and consume the stateless half — which is also the half worth previewing.
// commonMain — STATELESS. State in, events out. No DI, no ViewModel, no side effects.
@Composable
fun ReadingListContent(
state: ReadingState,
onItemClick: (String) -> Unit,
onRefresh: () -> Unit,
) {
when (state) {
is ReadingState.Loading -> LoadingList()
is ReadingState.Empty -> EmptyState(onRefresh)
is ReadingState.Ready -> LazyColumn {
items(state.items) { ReadingRow(it, onClick = { onItemClick(it.id) }) }
}
}
}
// commonMain — STATEFUL wrapper. The only part that touches DI.
@Composable
fun ReadingListScreen(viewModel: ReadingViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ReadingListContent(
state = state,
onItemClick = viewModel::onItemClick,
onRefresh = viewModel::refresh,
)
}
// androidMain — previews live here, and they get REAL states
@Preview(showBackground = true)
@Composable private fun PreviewLoading() =
AppTheme { ReadingListContent(ReadingState.Loading, {}, {}) }
@Preview(showBackground = true)
@Composable private fun PreviewEmpty() =
AppTheme { ReadingListContent(ReadingState.Empty, {}, {}) }
@Preview(showBackground = true, fontScale = 1.5f)
@Composable private fun PreviewReadyLargeFont() =
AppTheme { ReadingListContent(ReadingState.Ready(sampleItems), {}, {}) }
Three things this buys beyond getting previews back:
Every state is previewable, including the ones you could not reach before. The old preview
rendered whatever DI produced. These render Loading, Empty, and a large-font Ready — the
states that actually need visual checking and that were previously unreachable from the preview
pane.
The sample data lives in commonMain and is shared with tests:
// commonMain — one set of fixtures for previews, screenshot tests and unit tests
object ReadingSamples {
val items = listOf(Item("1", "The Pragmatic Programmer"), Item("2", "Crafting Interpreters"))
val ready = ReadingState.Ready(items)
}
And the desktop target becomes a real preview host, which is the part that recovers the design-review artifact:
// desktopTest — a screenshot test over the SAME stateless composable
@Test
fun readingListStates() = runDesktopComposeUiTest {
listOf("loading" to ReadingState.Loading,
"empty" to ReadingState.Empty,
"ready" to ReadingSamples.ready).forEach { (name, state) ->
setContent { AppTheme { ReadingListContent(state, {}, {}) } }
onRoot().captureRoboImage("reading-list-$name.png")
}
}
Three committed PNGs per screen, regenerated on demand, diffed in review. That is a better design artifact than an IDE pane, because it is durable and someone else can look at it.
How we proved it
That previews render every state. This is a visual claim, so the honest check is the generated images rather than an assertion:
$ ./gradlew :shared:desktopTest --tests '*ReadingListScreenshots*'
BUILD SUCCESSFUL
$ ls shared/src/desktopTest/roborazzi/
reading-list-empty.png
reading-list-loading.png
reading-list-ready.png
That the split did not change rendered output. The refactor claim is "same UI, different structure", and a screenshot comparison is exactly the tool for it:
$ ./gradlew :shared:compareRoborazziDesktop
reading-list-ready.png 0 pixels changed
reading-list-loading.png 0 pixels changed
reading-list-empty.png NEW — state was unreachable before the split
Zero pixels changed on the two states that existed before is the evidence that this was a refactor. The third line is the honest note that one state is new, not unchanged.
That the large-font case was actually broken, which the preview found immediately:
PreviewReadyLargeFont (fontScale = 1.5)
→ title truncated mid-word, subtitle clipped out of the row
That bug existed before the move and had never been seen, because the old preview could not
construct a Ready state. Getting previews back for real states found a live layout defect in the
first ten minutes — which is the strongest argument for the split that I have.
One limit worth stating. The screenshot tests run on the desktop target, so they verify the shared composable's layout under Compose's desktop renderer — not iOS text metrics, not Android font scaling on a real device. They catch structural layout regressions and they are not a substitute for looking at the thing on a phone.
What this generalizes to
Language features travel; tooling features do not. The compiler plugin shares, the IDE renderer does not. Expect this split at every portability boundary — a portable test framework with a platform-specific runner, a portable schema with a platform-specific inspector. Ask which half of a feature you are actually depending on.
A constraint that forces stateless components is doing you a favour. State-in/events-out is the recommended Compose structure for reasons that have nothing to do with previews: it is testable, reusable, and recomposition-friendly. The preview loss pushed us into the structure we should already have had.
If a thing cannot be previewed, make it screenshot-tested. Both answer "what does this look like". One is ephemeral and local; the other is durable, reviewable, and runs in CI. When the ephemeral option disappears, the durable one is usually an upgrade.
Share your fixtures. The sample data that makes a preview useful is the same data that makes a
test readable. Writing it once, in commonMain, means the preview and the test cannot disagree
about what a realistic state looks like.
Related
- Shared navigation has no back stack owner, and that is the hard part — the other Android-shaped assumption in shared UI
- The desktop target is the cheapest bug oracle you are not using — why desktop is the right screenshot host
- composeResources is not Android resources, and qualifiers resolve differently — borrowed Android vocabulary with different behaviour
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".