SupportingPaneScaffold is for content that is about other content
SupportingPaneScaffold docks secondary content beside a primary view on wide windows and collapses it to a sheet on narrow ones. Choosing it over list-detail is a question about what the second pane contains.

Day 32 of 100. Same machinery as yesterday, different question. The two scaffolds look almost identical in code, and picking the wrong one produces a screen that works and feels off.
The symptom
A video screen with comments, built as list-detail because that's the pattern you know:
val navigator = rememberListDetailPaneScaffoldNavigator<CommentId>()
ListDetailPaneScaffold(
listPane = { AnimatedPane { CommentList(onSelect = { … }) } },
detailPane = { AnimatedPane { VideoPlayer(video) } },
…
)
It renders two panes on a tablet. It also implies that comments are a collection you browse and the video is the detail of one — which is backwards. On a phone, opening the screen shows the comment list first and the video second, because that's what list-detail does.
The layout is right and the model is wrong, and the model is what determines what happens on the narrow window.
Why the obvious fix fails
The obvious fix is to swap the panes:
listPane = { AnimatedPane { VideoPlayer(video) } },
detailPane = { AnimatedPane { CommentList() } },
Now the phone shows the video first, which is right. And the semantics are still wrong: the navigator treats the comments pane as a destination, so on a phone the user navigates to comments and back returns to the video. That's defensible for comments and clearly wrong for, say, a document outline — you don't "navigate to" an outline, you glance at it.
More concretely: on a wide window, list-detail hides the list when detail opens on a medium-width screen. A video that disappears when you open comments is not what anyone wants.

The actual mechanism
SupportingPaneScaffold models primary content plus something that supports it:
@Composable
fun VideoScreen(video: Video) {
val navigator = rememberSupportingPaneScaffoldNavigator()
val scope = rememberCoroutineScope()
BackHandler(enabled = navigator.canNavigateBack()) {
scope.launch { navigator.navigateBack() }
}
SupportingPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
mainPane = {
AnimatedPane {
Column {
VideoPlayer(video)
VideoDescription(video)
Button(onClick = {
scope.launch {
navigator.navigateTo(SupportingPaneScaffoldRole.Supporting)
}
}) { Text("Comments") }
}
}
},
supportingPane = {
AnimatedPane { CommentList(video.id) }
},
)
}
Three differences from list-detail, all of them semantic rather than visual:
The main pane never hides. On any window that shows the supporting pane, the primary stays visible. That's the defining property — supporting content accompanies, it doesn't replace.
The supporting pane has no content key. rememberSupportingPaneScaffoldNavigator()
takes no type parameter, because there's nothing to select. It's a single thing that is
either shown or not.
The supporting pane is narrower. The directive gives the main pane the larger share by default, the reverse of list-detail's list.
On compact, the supporting pane is reached by an explicit action and back dismisses it.
On expanded it's simply docked, and canNavigateBack() returns false because nothing was
pushed — the same logic as yesterday, applied to a different model.
Which scaffold, decided in one question
Not "is there a second pane", but: does the second pane's content have an identity you could link to?
- A message in an inbox → yes, it has an id, you could deep-link it → list-detail.
- The outline of the document you're reading → no, it's derived from the primary → supporting pane.
- A photo in a grid → yes → list-detail.
- The filter controls for that grid → no → supporting pane.
- Comments on a video → borderline. If a comment is linkable and has its own view, list-detail; if it's a scrolling sidebar, supporting.
The borderline cases are genuinely borderline, and the tiebreaker that works: if the primary content disappeared while the second pane was open, would that be a bug? Yes → supporting pane.
Three panes here too
SupportingPaneScaffold also has an extraPane, used the same way — a third region that
appears only on very wide windows. A code editor with a file, an outline and a problems
list is the shape.
The roles are Main, Supporting and Extra, and as with list-detail you never set
visibility yourself; scaffoldValue derives it from the directive.
The compact-window decision
The one thing the scaffold doesn't decide for you is how the supporting pane appears on a narrow window. It gives you a destination-like navigation; whether that reads as a bottom sheet, a tab or a pushed screen is your call, and it should match the content:
- Bottom sheet for glanceable, dismissible support — filters, quick info.
- Tab when the two are peers on a phone even though one supports the other on a tablet — video and comments.
- Pushed screen for support that's substantial enough to deserve the whole window.
Whichever you choose, keeping it consistent across the app matters more than the specific choice, because it teaches users where secondary content lives.
How to prove it
The main-pane-never-hides property is the assertion worth writing:
@Test fun mainStaysVisibleWhenSupportingOpens() = runComposeUiTest {
setContent { ExpandedWindow { VideoScreen(sampleVideo) } }
onNodeWithTag("comments-button").performClick()
onNodeWithTag("player").assertIsDisplayed() // the point
onNodeWithTag("comments").assertIsDisplayed()
}
If that fails, you're in a list-detail scaffold wearing a supporting-pane name.
On device, the check is the medium window — around 700dp, the width where list-detail starts hiding a pane and supporting-pane doesn't. That's where the two diverge visibly, and it's a width most testing skips.
Sharing state between the panes
The two panes almost always read the same data, and the natural home for it is one ViewModel at the screen level — Day 12's rule applied to a two-pane screen:
@Composable
fun VideoScreen(vm: VideoViewModel = viewModel()) {
val state by vm.uiState.collectAsStateWithLifecycle()
val navigator = rememberSupportingPaneScaffoldNavigator()
SupportingPaneScaffold(
mainPane = { AnimatedPane { Player(state.video, onSeek = vm::seek) } },
supportingPane = { AnimatedPane { Comments(state.comments, onPost = vm::post) } },
…
)
}
One ViewModel, one state flow, both panes reading it. Giving each pane its own ViewModel is the tempting alternative and it reintroduces the two-sources-of-truth problem from Day 25 — posting a comment has to update a count in the main pane, and now there are two objects that both think they own it.
The navigator itself stays in the composition, because pane visibility is UI element state by Day 12's test: nobody would be upset if it reset.
What this generalizes to
The distinction is content with identity versus content that is derived. Anything addressable — it has an id, you could link to it, it could be a route — is a destination. Anything that only makes sense next to something else is a region.
Getting that wrong doesn't usually produce a visible bug on the device you're testing; it produces a navigation model that feels slightly wrong in ways users don't articulate. Which is why it's worth deciding deliberately rather than by whichever scaffold you implemented first.
The web calls the same split "pages versus panels", and REST calls it "does it have a URI". Three vocabularies, one question — and answering it early is what stops a navigation model from accumulating exceptions.
Tomorrow, Day 33: foldables — hinges, postures, and putting the split where the physical seam is.
Day 32 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Supporting pane layout.