--- name: compose-screens-footguns description: "Compose screen and interaction traps: navigation, adaptive layout, window insets, pagers, toolbars, sheets, list drag and selection, sliders, text fields, settings UI, one-shot effects. Use when layout, gestures or navigation misbehave." --- # Compose screens footguns: layout, navigation and interaction 32 traps mined from a production Kotlin and Compose Multiplatform app, one short file each under `references/`. Every file gives the working pattern, the **Traps** (the specific ways it fails in practice, and why), and a **Verifying it** section with commands to run against the user's own tree. **How to use this index.** Match the code about to be written, or the symptom being chased, against the entries below: each names its topic and the symptom that should send you to it. Read every file that plausibly applies with the Read tool before proposing code or a fix; each is under 150 lines. Where a Verifying command names the source project's paths, substitute the equivalent paths in the user's tree. **Cross-references.** A backticked trap name inside a file that is not listed here lives in a sibling area skill: glob `../*/references/.md` to open it. ## Screens, navigation and adaptive layout - [`responsive-gate-size-not-platform`](references/responsive-gate-size-not-platform.md) — Gate an adaptive layout on the window's own width-versus-height, never on the platform, and flip every geometry value the gate owns in the same breath — frame sizing, content scale and scrim height. Use when a layout is right on a phone and wrong in a narrow or resized desktop window, when forcing an adaptive flag to one value breaks a different platform's screen, or when artwork swallows the whole page on a wide window. - [`nav-tab-registration-drift`](references/nav-tab-registration-drift.md) — A top-level tab has to be registered in every navigation surface that holds its own copy of the tab list — bottom bar, rail, and a stylized bar that keeps two lists — plus the graph and the flag that gates it, or the tab exists in code and never renders. The same drift catches a status entry point carried by more than one top app bar, where the missing badge reads as "nothing is running" rather than as a bug. Covers why a tab's ordinal is an identity rather than a position, what a conditional tab needs when it disappears under the user, and why a badge dot needs a ring of the page colour. Use when a newly added tab or badge shows on one surface but not another, when selection highlights the wrong tab after reordering, or when a tab bar overflows once one more tab appears. - [`type-safe-nav-graph-organization`](references/type-safe-nav-graph-organization.md) — Lay out a type-safe Navigation-Compose graph so it stays readable as the destination list grows — one serializable route object per file grouped by area, area graphs as extension functions on the graph builder, a single transition set on the host, and per-route theming applied by wrapping the screen inside its own entry. Use when a navigation file has grown to hundreds of lines, when route arguments start needing custom types, or when transitions or theming differ between destinations for no stated reason. - [`collapsing-parallax-toolbar`](references/collapsing-parallax-toolbar.md) — Build a collapsing header from five siblings in one box — four sharing a single scroll state, one driven by a boolean instead — with artwork moved by a graphics layer at half the scroll rate, a title interpolated along a two-segment curve into the pinned bar, and a derived flip point that swaps the floating back button for a real top bar. Use when a parallax header jitters or re-measures while scrolling, when the collapsing title drifts off its intended path, or when the pinned bar appears at the wrong scroll offset after a window resize. - [`fullscreen-video-gesture-overlay`](references/fullscreen-video-gesture-overlay.md) — The gesture and chrome layers of a fullscreen video screen — tap anywhere to toggle the controls, double-tap either half to seek, an auto-hide timer that every interaction postpones, and a picture-in-picture (PiP) guard that removes both layers. Use when single taps work on only half the screen, when the controls vanish while the user is dragging the seek bar, when the seek thumb snaps back under the finger, when a double-tap produces no ripple or a ripple that never fades, or when app chrome is still drawn over the video inside a small floating window. - [`nowplaying-pager-no-feedback-loop`](references/nowplaying-pager-no-feedback-loop.md) — A now-playing artwork pager that both follows the player and drives it, without the two writing to each other in a loop — the in-progress-scroll flag covers programmatic animation as well as drags, the seek is dispatched from the settled page only, and the page-difference decision is a pure function outside the UI runtime. Use when a swipe bounces back to the page it came from, when one swipe skips two tracks, when the pager stops following the player after a fast swipe, when swiping backwards restarts the current track instead of going back one, or when a far swipe under shuffle lands on the wrong song. - [`layered-video-backdrop-pager`](references/layered-video-backdrop-pager.md) — Building a swipeable now-playing page whose layers — a colour backdrop, a full-bleed looping video or image, and a centred square cover — all belong to one pager, with per-page colours taken from the bitmap that page actually painted and a scrim spanning the whole page. Use when a swipe makes the background and the cover slide out of step, when video from one track bleeds into the neighbouring page mid-swipe, when the backdrop colour belongs to the wrong track or flashes black between pages, when the bottom of the video shows through the controls, or when a tap overlay stops the pager from being dragged at all. - [`desktop-mini-player-window`](references/desktop-mini-player-window.md) — A second always-on-top, frameless desktop window for playback controls — its existence held as one boolean outside the composition, the same state object as the main window, a hand-rolled drag that anchors to absolute pointer coordinates, a native minimum size in device pixels, and geometry persisted without flooding the store. Use when the small window drifts or jitters while being dragged, when it cannot be resized past a corner, when it collapses below its content, when it disappears the moment the main window is closed, when it opens invisible, or when its state disagrees with the main window's. - [`screen-shell-content-split`](references/screen-shell-content-split.md) — Split one screen into a shell that owns every cross-look concern and a content layer that only renders, connected by two holders — a state snapshot and an actions bag — so adding a second look costs one branch instead of a parameter-list edit; covers why the holders are stable-but-not-immutable, what must stay in the shell, and what must not. Use when a screen has grown a second visual style, when a style switch resets the scroll position or the artwork page, when adding a look means editing a fifty-argument signature, or when a "shared" helper starts needing a per-style `if`. - [`variant-layout-math-stays-in-the-variant`](references/variant-layout-math-stays-in-the-variant.md) — When two looks of one screen each need a fit-exactly-one-screen measurement — measure the fixed blocks, split the remainder into equal gaps, floor it at a minimum — keep a copy per look instead of hoisting one; covers the effect keys the block needs, the invisible spacer that must mirror the ratio actually drawn, and which spacer may animate. Use when the gap above or below a hero element keeps last track's size, when content that should end at the fold overflows or leaves a band of dead space, or before extracting "the same" layout maths from two screens into one helper. - [`empty-state-must-keep-its-navigation`](references/empty-state-must-keep-its-navigation.md) — Replace a populated header with an empty-state message without deleting the controls that header owned — re-supply them only in the branch that owned them, order the loading branch above the empty one, and stop reserving the artwork's height for a line of text. Use when a user reaches an empty period, filter or search result and cannot get back out, when an empty message flashes on every reload, or when the same control renders twice. - [`stacked-bars-double-consume-window-insets`](references/stacked-bars-double-consume-window-insets.md) — Inset consumption travels to a composable's descendants and never to its siblings, so two inset-aware bars stacked in one column each reserve the system bar and open a band of dead space exactly one bar tall. Covers parameterising a bar's `windowInsets` with the framework default, deciding once who consumes, and why the same component must keep the default at its overlay call sites. Use when a strip of empty space appears between two bars, when it only shows in one mode of a screen, or when a bar's leading icon is clipped in landscape after you zeroed its insets. - [`floating-overlay-reserves-its-own-strip`](references/floating-overlay-reserves-its-own-strip.md) — A control floated over a scrolling column is a sibling that takes part in no measurement, so the column has to reserve the strip it covers explicitly — as a leading spacer, or as a header row whose twin spacers keep a centred title centred over the hole. Covers what the strip constant may and may not count, why every scrolling branch needs its own, and why floating is what keeps the control reachable once the page has scrolled. Use when a floating back button sits on top of the first line of content, when a centred title is off by half a button, or when a control scrolls out of reach on a long page. ## Components and interaction - [`lazy-list-drag-reorder`](references/lazy-list-drag-reorder.md) — A complete drag-to-reorder state holder for a lazily composed Compose list — pointer offset accumulation, target-index math over the visible window, how the lift animation and the built-in item placement animation must not overlap, edge auto-scroll as a delta the caller drives, and the commit-on-drop contract with the data layer. Use when building reorder, or when a dragged row snaps back to its old slot, jitters as the list re-lays-out under it, commits a move that the user cancelled, or scrolls the list instead of moving the row. - [`swipe-action-list-row`](references/swipe-action-list-row.md) — One list row carrying three gestures at once — tap, long-press-to-select, and swipe-sideways-for-an-action — plus a mode flag that changes what the tap means. Covers the pointerInput key that decides whether the swipe detector sees the current mode or the one captured at composition, translating the row in the layout phase, latching the commit threshold, and leaving the opposite drag direction to the parent. Use when a row keeps swiping after multi-select has started, when selection only begins working after the row happens to recompose, or when a swipe fights the pager or list underneath. - [`selection-mode-state-holder`](references/selection-mode-state-holder.md) — A small @Stable holder for multi-select in a Compose list — keyed by stable id rather than list position, with every mutation funnelled through one private method so a hard cap on the selection size cannot be bypassed, plus the toggle/clear/select-all semantics that make the two gestures read differently. Use when building bulk actions over a list, when selections drift onto the wrong rows after a reorder or a page load, or when a cap holds for tapping rows but not for select-all. - [`expandable-and-linkified-text`](references/expandable-and-linkified-text.md) — Clamp long text to a few lines with a more/less affordance driven by measured overflow instead of a character count, and make timestamps or URLs inside the same text tappable. Use when "more" shows on text that already fits, never shows on text that does not, gets truncated along with the text, or when tapping a link expands the block instead of following the link. - [`lazy-scroll-helper-kit`](references/lazy-scroll-helper-kit.md) — Four small lazy-list utilities worth carrying between apps — scroll-direction as derived state, centre-an-item scrolling that waits a frame before measuring, an item's visible percentage, and a lookup into the visible window — with the trap each one hides. Use when a hide-on-scroll bar flickers or sticks, when scrolling to an item lands it at the edge or does nothing, or when viewport arithmetic returns values for the wrong item. - [`custom-thin-media-slider`](references/custom-thin-media-slider.md) — A slim seek bar with a buffered-progress track behind it, built from Material3's Slider with custom track and thumb slots — including the fraction-not-your-own-scale rule that keeps the thumb from pinning at the end, and the state gate that stops incoming playback position from fighting the drag. Use when a seek bar renders full or empty regardless of position, when the thumb snaps back while dragging, when a thin control refuses to get thinner, or when stray dots and ticks appear on the track. - [`custom-modal-sheet-family`](references/custom-modal-sheet-family.md) — A house style over Material3's ModalBottomSheet — transparent container plus your own surface, zeroed window insets with an explicit end spacer, a hand-rolled drag handle, and hide-then-dismiss so the sheet animates closed before it leaves composition. Use when a sheet snaps shut instead of sliding, when its last row sits under the navigation bar, when text inside it is invisible, or when a family of sheets has drifted into a family of slightly different sheets. - [`control-range-must-cover-stored-values`](references/control-range-must-cover-stored-values.md) — Size a slider's range from the real distribution of values it will be handed — stored, imported, migrated — rather than from a neighbouring control's range, because a value outside the range parks the thumb at the end of the track while your readout shows a different number, and the first touch silently rewrites it. Use when adding a slider for a value that can arrive from anywhere but the slider itself. - [`edit-a-shape-as-a-shape`](references/edit-a-shape-as-a-shape.md) — When the value being edited is a curve, draw a draggable curve instead of N sliders and embed it in the settings list instead of pushing a screen — with a raw pointer loop rather than a drag-gesture helper, a draft that commits once per gesture, and smoothing that never overshoots a handle the user placed. Use when building a multi-point editor, or when a curve control ignores taps, snaps back on release, or wipes the saved value. - [`progress-indicator-as-scrubber`](references/progress-indicator-as-scrubber.md) — Build a seek control from a progress indicator plus a transparent pointer layer instead of from a slider — a hit box taller than the visual, drags consumed so an ancestor pager cannot steal a scrub, the drag's own fraction shown while interacting, a thumb drawn by you, and the separate decision of drawing an element closer without moving its layout slot. Use when the visual you need has no slider equivalent, when a scrub gets hijacked by a swipe or a sheet, when the bar jumps back mid-drag, or when a thin control is impossible to hit. - [`scoped-composable-shadows-the-top-level-one`](references/scoped-composable-shadows-the-top-level-one.md) — Inside a layout scope, a scope-extension composable of the same name wins over the top-level one — silently, since both compile — so a call written for the plain version gets the scoped version's defaults and layout behaviour; covers fully qualifying to force the top-level one, `this@Scope.` to force the scoped one, and why outer scopes still apply from inside a nested layout. Use when an appear/disappear animation expands or collapses its parent instead of fading in place, when a composable behaves differently after being moved into a column or row, or when a call resolves to an overload you did not choose. - [`settings-value-round-tripped-through-its-label`](references/settings-value-round-tripped-through-its-label.md) — A selection dialog that maps the chosen localized label back to a stored value breaks the day two labels translate identically — the write is skipped or lands on the wrong option, with no error; covers carrying the id instead of the text, making the miss loud, and the sibling hazard of a default declared both in the store and as the collector's initial value. Use when a settings choice does not stick in one language only, when a picker writes a neighbouring option, or when a screen renders the wrong variant for a moment on entry. - [`self-hiding-child-cannot-hide-its-slot`](references/self-hiding-child-cannot-hide-its-slot.md) — A component that renders nothing when its feature is unavailable cannot remove the container someone else wrapped it in — gate the slot on the same availability predicate, publish that predicate beside the component, and branch the slot's clickable and non-clickable forms properly. Use when an empty cell, gap or stray divider appears where an optional control should be, when a group's rounded end caps land on the wrong item, or when a wrapper swallows the taps meant for the control inside it. - [`event-not-state-edge-for-one-shot-effects`](references/event-not-state-edge-for-one-shot-effects.md) — Fire a one-shot celebration effect from the click that caused it, never from the state that click produced — an effect watching a boolean for a false→true edge cannot tell a tap from data arriving a beat later, so moving to an already-marked record fires the same edge. Covers the @Stable holder that owns live effects, judging the meaning at tap time, why the previous-value variable is a second source of truth, and keeping concurrent effects additive. Use when an effect plays by itself while skipping between records, when it celebrates something the user did not do, or when the first genuine tap after a screen returns is silently swallowed. - [`weight-fill-false-to-center-a-cluster`](references/weight-fill-false-to-center-a-cluster.md) — A weighted child occupies its whole slot even when its content measures narrower, which pins the sibling beside it to the far edge; `weight(1f, fill = false)` releases the unused width back to the row's arrangement so child, gap and sibling read as one cluster. Covers why the tell is the sibling rather than the weighted child, why the fix is two edits and not one, and why dropping the weight instead is a different layout. Use when a bar looks centred at one content size and lopsided at another, when a button clings to the screen edge with a hole beside it, or when a trailing label sits far from the text it belongs to. - [`lazy-item-grouping-beats-arrangement-gap`](references/lazy-item-grouping-beats-arrangement-gap.md) — A lazy list's `spacedBy` arrangement applies between every pair of items and compounds with each item's own edge padding, so blocks that must read as one unit belong in ONE item carrying its own tighter spacing rather than in three items relying on the list's gap. Covers why the visible gap appears in no single constant, why an item boundary is a spacing boundary, and what to do when the group is conditional. Use when a band of dead space opens above one block, when tightening the gap for one pair moves every other pair, or before splitting a header into separate lazy items. - [`commit-a-text-field-on-an-explicit-action`](references/commit-a-text-field-on-an-explicit-action.md) — An editable value that is written on focus loss stores whatever was half-typed when a dialog, a rotation or a stray tap took the focus away; keep the draft in its own state keyed on the stored value and write only when the user asks for it. Covers why the dirty check that shows the confirm button gets stuck when the commit normalises, where the draft should live, and what a second commit path has to agree with. Use when a setting holds a truncated value nobody typed, when a Save button never goes away after saving, or when an externally changed value does not reach the field. - [`story-reel-auto-pager`](references/story-reel-auto-pager.md) — A story-style reel — a pager that advances itself on a per-card timer, with a segmented progress bar, tap zones to skip forward or back, and a press-and-hold that pauses it — where every card's data is computed once before the first frame instead of card by card, and the segmented bar's count comes from a card list some years never fill completely. Covers a frame-delta timer that a long hold cannot bank progress against, why an empty onLongPress callback is what makes hold-then-release resume instead of navigate, reading the pager's target page rather than its current one inside a tap handler, and pinning a captured card's colour scheme so it renders the same regardless of the viewer's own theme. Use when a reel's progress bar jumps to the wrong segment, when releasing a paused hold immediately skips a card instead of resuming it, when a loading spinner appears mid-story instead of only before the first card, or when a shared card looks different depending on which theme the device was in. - [`hidden-setting-does-not-clear-the-stored-value`](references/hidden-setting-does-not-clear-the-stored-value.md) — A style or effect option is correctly hidden from a settings picker below some OS version or capability floor, yet the effect it names still shows up broken — flat, unblurred, or simply wrong — on a device that should never be able to select it, because the picker gates what a user can choose next, not what a stored preference already holds. Use when a version-gated visual feature has a capability check in one place but the bug still reproduces, or when adding an expect/actual boolean for a modifier that fails silently instead of throwing.