
Sleek morphing popup selector with fluid transition, flying-selected-item animation, smart axis alignment, keyboard navigation, pluggable visual effects (blur/haze), and fully overridable sizing and styling.
A sleek, fluid morphing popup selector component for Compose Multiplatform (Android, JVM/Desktop, iOS).
Top/Bottom, Start/End) via PopupVerticalAlignment / PopupHorizontalAlignment.Enter/Space select, Esc dismisses. Fully operable on desktop.anchorModifier and surfaceModifier.In your module's build.gradle.kts (e.g., commonMain):
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.viel0320:popupselector:2.1.0")
}
}
}[!NOTE] Ensure
mavenCentral()is declared in your rootsettings.gradle.ktsrepositories block.
[!NOTE] Material 3 is not exposed transitively at compile time. The library reads your
MaterialThemefor its default colors, but if your module uses Material 3 APIs itself (as most Compose apps do), declare thecompose.material3dependency explicitly.
The library is also published to GitHub Packages.
GitHub's Maven registry requires authentication even for public packages, so declare the
repository with credentials (a GitHub username plus a token with read:packages) in your
settings.gradle.kts:
dependencyResolutionManagement {
repositories {
maven {
url = uri("https://maven.pkg.github.com/Viel0320/Popupselector")
credentials {
username = findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR")
password = findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN")
}
}
}
}import androidx.compose.runtime.*
import com.viel.compose.popupselector.*
@Composable
fun FilterSelector() {
var expanded by remember { mutableStateOf(false) }
var selectedIndex by remember { mutableStateOf<Int?>(0) }
val items = remember {
listOf(
textPopupItem(key = "all", label = "All Items", count = 128),
textPopupItem(key = "favorites", label = "Favorites", count = 12),
textPopupItem(key = "archived", label = "Archived", count = 3),
textPopupItem(key = "trash", label = "Trash", enabled = false),
)
}
PopupSelector(
items = items,
expanded = expanded,
selectedIndex = selectedIndex,
onExpandedChange = { expanded = it },
onSelect = { selectedIndex = it },
layout = PopupLayout(width = PopupWidth.Wrap),
)
}You can provide fully customized item composables for each option:
val items = remember {
listOf(
PopupItem(
key = "daily",
count = { Text("7:00 AM", style = MaterialTheme.typography.bodySmall) },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Today, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Daily Digest")
}
},
PopupItem(key = "weekly") {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.DateRange, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Weekly Summary")
}
},
)
}
PopupSelector(
items = items,
expanded = expanded,
selectedIndex = selectedIndex,
onExpandedChange = { expanded = it },
onSelect = { selectedIndex = it },
layout = PopupLayout(width = PopupWidth.Fixed(280.dp)),
)Set via PopupLayout(width = ...). Three strategies are available:
| Strategy | Description |
|---|---|
PopupWidth.Wrap |
(Default) Fits the natural intrinsic width of the widest row item (constrained by screen margins), never narrower than the trigger. |
PopupWidth.MatchAnchor |
Expands to the exact measured width of the collapsed trigger button. |
PopupWidth.Fixed(width) |
Uses an explicit fixed width (e.g.PopupWidth.Fixed(260.dp)). |
[!NOTE] With
MatchAnchoror a narrowFixedwidth, overflowing labels marquee-scroll, but the trailing count slot is laid out at its shared section width and can be clipped if the panel is narrower than the content and count sections combined. PreferPopupWidth.Wrapwhen items carry counts.
Set via PopupLayout(verticalAlignment = ..., horizontalAlignment = ...). The two axes resolve
independently, so the panel can, say, open downward while hugging the trailing edge.
Vertical — PopupVerticalAlignment:
Auto: (Default) With a selection, aligns the selected row's vertical centre with the trigger's centre, so the current selection stays put as the panel opens. Without a selection, compares the room above and below and picks the side that fits.Top / Bottom: Pin the panel to that side of the trigger.Horizontal — PopupHorizontalAlignment:
Auto: (Default) Hugs the trigger's leading side when both sides fit; opens toward the only side with room. Mirrors with the layout direction.Start / End: Pin a direction-aware side (mirrors in RTL).Both axes also offer Inherit, which follows the matching component of LocalPopupAlignment —
the ambient alignment a subtree provides with CompositionLocalProvider (typically alongside a
Box(contentAlignment = ...)), falling back to Auto when none is provided.
Explicit placements are honoured whether or not an item is selected. Only Auto repositions the
panel vertically to match the selection.
You can customize the content inside the collapsed button (ideal for dropdown action menus).
collapsedContent is the last parameter, so it can be passed as a trailing lambda:
PopupSelector(
items = menuItems,
expanded = expanded,
onExpandedChange = { expanded = it },
onSelect = { index -> handleMenuAction(index) },
) {
Text("Actions Menu", fontWeight = FontWeight.Bold)
}To use real-time blur libraries like Haze, wrap your root layout in PopupOverlayContainer (or PopupOverlayHost) so the panel shares the same window and drawing tree with hazeSource (isolated native Popup windows cannot sample parent pixels). Combine with PopupDefaults.blurColors() for preconfigured transparent surfaces.
[!IMPORTANT] Dual-State Background Sampling Setup (Avoiding Color Flickering / Flashing)
During morph animations, the selector smoothly transitions from the collapsed trigger to the expanded panel:
- Collapsed State: Provide a background sampling source (
hazeSource) beneath the anchor button and passanchorModifier = Modifier.hazeEffect(...). Note that the button must be layered on top of the background layer (not as a child inside thehazeSourcecomposable) so Haze can sample behind it.- Expanded State: Provide a broader page/screen-level background sampling source (
hazeSource) so the floating panel can sample correctly even when expanding outside local cards, usingsurfaceModifier = Modifier.hazeEffect(...).Setting up sampling sources for both layers ensures seamless color and blur interpolation during the morph transition without visual glitches or color flashing.
val rootHazeState = remember { HazeState() }
val parentHazeState = remember { HazeState() }
// Wrap your root/screen in PopupOverlayContainer to enable seamless in-window blur sampling
PopupOverlayContainer(Modifier.fillMaxSize()) {
// 1. Root layer registered as hazeSource (for expanded panel & full-screen blur)
Column(
modifier = Modifier
.fillMaxSize()
.hazeSource(rootHazeState)
.padding(16.dp)
) {
// 2. Direct parent container with background registered as hazeSource (for collapsed anchor button)
Box(
modifier = Modifier
.background(Color.Blue.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.hazeSource(parentHazeState)
.padding(16.dp)
) {
PopupSelector(
items = items,
expanded = expanded,
selectedIndex = selectedIndex,
onExpandedChange = { expanded = it },
onSelect = { selectedIndex = it },
style = PopupDefaults.style(colors = PopupDefaults.blurColors()),
anchorModifier = Modifier.hazeEffect(state = parentHazeState, style = HazeMaterials.ultraThin()),
surfaceModifier = Modifier.hazeEffect(state = rootHazeState, style = HazeMaterials.ultraThin()),
)
}
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
items |
List<PopupItem> |
(Required) | List of options to display in the selector. |
expanded |
Boolean |
(Required) | Whether the floating panel is currently expanded. |
onExpandedChange |
(Boolean) -> Unit |
(Required) | Callback when the expansion state changes. |
onSelect |
(index: Int) -> Unit |
(Required) | Callback invoked when an item is selected. |
modifier |
Modifier |
Modifier |
Modifier applied to the outer layout container. |
selectedIndex |
Int? |
null |
Index of the currently selected option. |
style |
PopupStyle |
PopupDefaults.style() |
Visual configuration: metrics, timing, colours, typography, animation. |
layout |
PopupLayout |
PopupLayout() |
Layout configuration: width, max height, alignment, host. |
anchorModifier |
Modifier |
Modifier |
Modifier applied to the resting collapsed button surface. |
surfaceModifier |
Modifier |
Modifier |
Modifier applied to the expanding floating surface. |
collapsedContent |
(@Composable RowScope.() -> Unit)? |
null |
Custom composable slot for the collapsed trigger button. Last, so it can be passed as a trailing lambda. |
| Field | Type | Default | Description |
|---|---|---|---|
width |
PopupWidth |
PopupWidth.Wrap |
Panel width strategy:Wrap,MatchAnchor, or Fixed(width). |
maxHeight |
Dp |
Dp.Unspecified |
Maximum panel height. Unspecified uses 60% of the window height. |
verticalAlignment |
PopupVerticalAlignment |
Auto |
Vertical placement:Auto, Top, Bottom, Inherit. See Expansion Origin. |
horizontalAlignment |
PopupHorizontalAlignment |
Auto |
Horizontal placement:Auto, Start, End, Inherit. |
host |
PopupHostPolicy |
PopupHostPolicy.Overlay() |
Where the panel is hosted:Window or Overlay(state?). |
| Field | Type | Default | Description |
|---|---|---|---|
colors |
PopupColors |
(Required) — PopupDefaults.colors()
|
Collapsed/expanded containers, border, content, activeContent (the selected row's text colour, defaulting to the theme's primary). |
metrics |
PopupMetrics |
PopupMetrics.Default |
Sizes, spacing, corner radius, elevations. |
timing |
PopupTiming |
PopupTiming() |
Morph thresholds and stagger constants. |
textStyle |
TextStyle? |
null |
Typography for labels. Defaults to ambientLocalTextStyle. |
animationSpec |
AnimationSpec<Float> |
spring (damping 0.9, medium-low stiffness) | Main morph and reveal animation spec. |
sizeAnimationSpec |
FiniteAnimationSpec<IntSize> |
spring (damping 0.9, medium-low stiffness) | Size change animation spec for the collapsed anchor. |
autoMarquee |
Boolean |
true |
Whether overflowing rows scroll with a marquee. |
All values are @Stable data class fields, so you can override any subset:
collapsedHeight (32.dp) · collapsedTouchTarget (48.dp) · rowTouchTargetHeight (48.dp) ·
rowVerticalSpacing (4.dp) · panelVerticalPadding (8.dp) · collapsedHorizontalPadding (16.dp) ·
rowHorizontalPadding (12.dp) · chevronLeadingPadding (8.dp) · countSpacing (12.dp) ·
borderWidth (1.dp) · chevronSize (18.dp) · rowEnterTranslation (14.dp) · edgeMargin (8.dp) ·
maxHeightFraction (0.6f) · disabledAlpha (0.38f) · corner (8.dp) · collapsedElevation (0.dp) ·
expandedElevation (6.dp)
visibleEpsilon (0.0001f) · morphCompleteThreshold (0.999f) · chevronExpandFadeSpan (0.25f) ·
chevronCollapseFadeSpan (0.25f) · staggerBase (0.15f) · staggerStep (0.06f) ·
staggerMax (0.6f) · borderMinWidth (0.25f) · containerMinAlpha (0.001f) ·
shadowStartFraction (0.85f) · anchorHighlightFadeMillis (90 ms)
shadowStartFraction delays the expanding surface's shadow until the morph is that far along, so a
low-elevation shadow doesn't read as a dark rim around transparent containers — set 0f for an
always-on ramp. anchorHighlightFadeMillis is the collapsed trigger's press/hover highlight fade
duration.
| Field | Type | Default | Description |
|---|---|---|---|
key |
Any? |
null |
Stable identity for the option. |
enabled |
Boolean |
true |
Whether the option can be selected. |
count |
(@Composable RowScope.() -> Unit)? |
null |
Optional trailing slot, e.g. a count badge. |
content |
@Composable RowScope.() -> Unit |
— | Primary row content. |
The expanded panel takes focus automatically and supports:
| Key | Action |
|---|---|
↓ / Tab
|
Move the cursor to the next enabled option. |
↑ |
Move the cursor to the previous option. |
Enter / Space
|
Select the focused option and dismiss. |
Esc |
Dismiss without changing the selection. |
Disabled options are skipped by arrow-key navigation. While the panel collapses, its rows leave
the accessibility tree, and the surface unmounts entirely once collapsed, so the fading panel is
never announced. The collapsed trigger announces its expansion state via stateDescription
("Expanded" / "Collapsed"); Compose currently has no localizable expanded-state semantic property,
so these strings are supplied by the library in English.
[!TIP] Animation & State Handoff in Debug Builds
Due to Compose runtime overhead and unoptimized recompositions in Debug builds, the 1-frame transition/handoff interval between the collapsed anchor and the morphing surface may occasionally be amplified.
In Release builds (with R8 / compiler optimizations enabled), this handoff is seamless and transitions run smoothly at full frame rate. Always evaluate animation fluidity on Release builds.
Copyright 2026 Viel
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
A sleek, fluid morphing popup selector component for Compose Multiplatform (Android, JVM/Desktop, iOS).
Top/Bottom, Start/End) via PopupVerticalAlignment / PopupHorizontalAlignment.Enter/Space select, Esc dismisses. Fully operable on desktop.anchorModifier and surfaceModifier.In your module's build.gradle.kts (e.g., commonMain):
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.viel0320:popupselector:2.1.0")
}
}
}[!NOTE] Ensure
mavenCentral()is declared in your rootsettings.gradle.ktsrepositories block.
[!NOTE] Material 3 is not exposed transitively at compile time. The library reads your
MaterialThemefor its default colors, but if your module uses Material 3 APIs itself (as most Compose apps do), declare thecompose.material3dependency explicitly.
The library is also published to GitHub Packages.
GitHub's Maven registry requires authentication even for public packages, so declare the
repository with credentials (a GitHub username plus a token with read:packages) in your
settings.gradle.kts:
dependencyResolutionManagement {
repositories {
maven {
url = uri("https://maven.pkg.github.com/Viel0320/Popupselector")
credentials {
username = findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR")
password = findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN")
}
}
}
}import androidx.compose.runtime.*
import com.viel.compose.popupselector.*
@Composable
fun FilterSelector() {
var expanded by remember { mutableStateOf(false) }
var selectedIndex by remember { mutableStateOf<Int?>(0) }
val items = remember {
listOf(
textPopupItem(key = "all", label = "All Items", count = 128),
textPopupItem(key = "favorites", label = "Favorites", count = 12),
textPopupItem(key = "archived", label = "Archived", count = 3),
textPopupItem(key = "trash", label = "Trash", enabled = false),
)
}
PopupSelector(
items = items,
expanded = expanded,
selectedIndex = selectedIndex,
onExpandedChange = { expanded = it },
onSelect = { selectedIndex = it },
layout = PopupLayout(width = PopupWidth.Wrap),
)
}You can provide fully customized item composables for each option:
val items = remember {
listOf(
PopupItem(
key = "daily",
count = { Text("7:00 AM", style = MaterialTheme.typography.bodySmall) },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Today, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Daily Digest")
}
},
PopupItem(key = "weekly") {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.DateRange, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Weekly Summary")
}
},
)
}
PopupSelector(
items = items,
expanded = expanded,
selectedIndex = selectedIndex,
onExpandedChange = { expanded = it },
onSelect = { selectedIndex = it },
layout = PopupLayout(width = PopupWidth.Fixed(280.dp)),
)Set via PopupLayout(width = ...). Three strategies are available:
| Strategy | Description |
|---|---|
PopupWidth.Wrap |
(Default) Fits the natural intrinsic width of the widest row item (constrained by screen margins), never narrower than the trigger. |
PopupWidth.MatchAnchor |
Expands to the exact measured width of the collapsed trigger button. |
PopupWidth.Fixed(width) |
Uses an explicit fixed width (e.g.PopupWidth.Fixed(260.dp)). |
[!NOTE] With
MatchAnchoror a narrowFixedwidth, overflowing labels marquee-scroll, but the trailing count slot is laid out at its shared section width and can be clipped if the panel is narrower than the content and count sections combined. PreferPopupWidth.Wrapwhen items carry counts.
Set via PopupLayout(verticalAlignment = ..., horizontalAlignment = ...). The two axes resolve
independently, so the panel can, say, open downward while hugging the trailing edge.
Vertical — PopupVerticalAlignment:
Auto: (Default) With a selection, aligns the selected row's vertical centre with the trigger's centre, so the current selection stays put as the panel opens. Without a selection, compares the room above and below and picks the side that fits.Top / Bottom: Pin the panel to that side of the trigger.Horizontal — PopupHorizontalAlignment:
Auto: (Default) Hugs the trigger's leading side when both sides fit; opens toward the only side with room. Mirrors with the layout direction.Start / End: Pin a direction-aware side (mirrors in RTL).Both axes also offer Inherit, which follows the matching component of LocalPopupAlignment —
the ambient alignment a subtree provides with CompositionLocalProvider (typically alongside a
Box(contentAlignment = ...)), falling back to Auto when none is provided.
Explicit placements are honoured whether or not an item is selected. Only Auto repositions the
panel vertically to match the selection.
You can customize the content inside the collapsed button (ideal for dropdown action menus).
collapsedContent is the last parameter, so it can be passed as a trailing lambda:
PopupSelector(
items = menuItems,
expanded = expanded,
onExpandedChange = { expanded = it },
onSelect = { index -> handleMenuAction(index) },
) {
Text("Actions Menu", fontWeight = FontWeight.Bold)
}To use real-time blur libraries like Haze, wrap your root layout in PopupOverlayContainer (or PopupOverlayHost) so the panel shares the same window and drawing tree with hazeSource (isolated native Popup windows cannot sample parent pixels). Combine with PopupDefaults.blurColors() for preconfigured transparent surfaces.
[!IMPORTANT] Dual-State Background Sampling Setup (Avoiding Color Flickering / Flashing)
During morph animations, the selector smoothly transitions from the collapsed trigger to the expanded panel:
- Collapsed State: Provide a background sampling source (
hazeSource) beneath the anchor button and passanchorModifier = Modifier.hazeEffect(...). Note that the button must be layered on top of the background layer (not as a child inside thehazeSourcecomposable) so Haze can sample behind it.- Expanded State: Provide a broader page/screen-level background sampling source (
hazeSource) so the floating panel can sample correctly even when expanding outside local cards, usingsurfaceModifier = Modifier.hazeEffect(...).Setting up sampling sources for both layers ensures seamless color and blur interpolation during the morph transition without visual glitches or color flashing.
val rootHazeState = remember { HazeState() }
val parentHazeState = remember { HazeState() }
// Wrap your root/screen in PopupOverlayContainer to enable seamless in-window blur sampling
PopupOverlayContainer(Modifier.fillMaxSize()) {
// 1. Root layer registered as hazeSource (for expanded panel & full-screen blur)
Column(
modifier = Modifier
.fillMaxSize()
.hazeSource(rootHazeState)
.padding(16.dp)
) {
// 2. Direct parent container with background registered as hazeSource (for collapsed anchor button)
Box(
modifier = Modifier
.background(Color.Blue.copy(alpha = 0.3f), RoundedCornerShape(12.dp))
.hazeSource(parentHazeState)
.padding(16.dp)
) {
PopupSelector(
items = items,
expanded = expanded,
selectedIndex = selectedIndex,
onExpandedChange = { expanded = it },
onSelect = { selectedIndex = it },
style = PopupDefaults.style(colors = PopupDefaults.blurColors()),
anchorModifier = Modifier.hazeEffect(state = parentHazeState, style = HazeMaterials.ultraThin()),
surfaceModifier = Modifier.hazeEffect(state = rootHazeState, style = HazeMaterials.ultraThin()),
)
}
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
items |
List<PopupItem> |
(Required) | List of options to display in the selector. |
expanded |
Boolean |
(Required) | Whether the floating panel is currently expanded. |
onExpandedChange |
(Boolean) -> Unit |
(Required) | Callback when the expansion state changes. |
onSelect |
(index: Int) -> Unit |
(Required) | Callback invoked when an item is selected. |
modifier |
Modifier |
Modifier |
Modifier applied to the outer layout container. |
selectedIndex |
Int? |
null |
Index of the currently selected option. |
style |
PopupStyle |
PopupDefaults.style() |
Visual configuration: metrics, timing, colours, typography, animation. |
layout |
PopupLayout |
PopupLayout() |
Layout configuration: width, max height, alignment, host. |
anchorModifier |
Modifier |
Modifier |
Modifier applied to the resting collapsed button surface. |
surfaceModifier |
Modifier |
Modifier |
Modifier applied to the expanding floating surface. |
collapsedContent |
(@Composable RowScope.() -> Unit)? |
null |
Custom composable slot for the collapsed trigger button. Last, so it can be passed as a trailing lambda. |
| Field | Type | Default | Description |
|---|---|---|---|
width |
PopupWidth |
PopupWidth.Wrap |
Panel width strategy:Wrap,MatchAnchor, or Fixed(width). |
maxHeight |
Dp |
Dp.Unspecified |
Maximum panel height. Unspecified uses 60% of the window height. |
verticalAlignment |
PopupVerticalAlignment |
Auto |
Vertical placement:Auto, Top, Bottom, Inherit. See Expansion Origin. |
horizontalAlignment |
PopupHorizontalAlignment |
Auto |
Horizontal placement:Auto, Start, End, Inherit. |
host |
PopupHostPolicy |
PopupHostPolicy.Overlay() |
Where the panel is hosted:Window or Overlay(state?). |
| Field | Type | Default | Description |
|---|---|---|---|
colors |
PopupColors |
(Required) — PopupDefaults.colors()
|
Collapsed/expanded containers, border, content, activeContent (the selected row's text colour, defaulting to the theme's primary). |
metrics |
PopupMetrics |
PopupMetrics.Default |
Sizes, spacing, corner radius, elevations. |
timing |
PopupTiming |
PopupTiming() |
Morph thresholds and stagger constants. |
textStyle |
TextStyle? |
null |
Typography for labels. Defaults to ambientLocalTextStyle. |
animationSpec |
AnimationSpec<Float> |
spring (damping 0.9, medium-low stiffness) | Main morph and reveal animation spec. |
sizeAnimationSpec |
FiniteAnimationSpec<IntSize> |
spring (damping 0.9, medium-low stiffness) | Size change animation spec for the collapsed anchor. |
autoMarquee |
Boolean |
true |
Whether overflowing rows scroll with a marquee. |
All values are @Stable data class fields, so you can override any subset:
collapsedHeight (32.dp) · collapsedTouchTarget (48.dp) · rowTouchTargetHeight (48.dp) ·
rowVerticalSpacing (4.dp) · panelVerticalPadding (8.dp) · collapsedHorizontalPadding (16.dp) ·
rowHorizontalPadding (12.dp) · chevronLeadingPadding (8.dp) · countSpacing (12.dp) ·
borderWidth (1.dp) · chevronSize (18.dp) · rowEnterTranslation (14.dp) · edgeMargin (8.dp) ·
maxHeightFraction (0.6f) · disabledAlpha (0.38f) · corner (8.dp) · collapsedElevation (0.dp) ·
expandedElevation (6.dp)
visibleEpsilon (0.0001f) · morphCompleteThreshold (0.999f) · chevronExpandFadeSpan (0.25f) ·
chevronCollapseFadeSpan (0.25f) · staggerBase (0.15f) · staggerStep (0.06f) ·
staggerMax (0.6f) · borderMinWidth (0.25f) · containerMinAlpha (0.001f) ·
shadowStartFraction (0.85f) · anchorHighlightFadeMillis (90 ms)
shadowStartFraction delays the expanding surface's shadow until the morph is that far along, so a
low-elevation shadow doesn't read as a dark rim around transparent containers — set 0f for an
always-on ramp. anchorHighlightFadeMillis is the collapsed trigger's press/hover highlight fade
duration.
| Field | Type | Default | Description |
|---|---|---|---|
key |
Any? |
null |
Stable identity for the option. |
enabled |
Boolean |
true |
Whether the option can be selected. |
count |
(@Composable RowScope.() -> Unit)? |
null |
Optional trailing slot, e.g. a count badge. |
content |
@Composable RowScope.() -> Unit |
— | Primary row content. |
The expanded panel takes focus automatically and supports:
| Key | Action |
|---|---|
↓ / Tab
|
Move the cursor to the next enabled option. |
↑ |
Move the cursor to the previous option. |
Enter / Space
|
Select the focused option and dismiss. |
Esc |
Dismiss without changing the selection. |
Disabled options are skipped by arrow-key navigation. While the panel collapses, its rows leave
the accessibility tree, and the surface unmounts entirely once collapsed, so the fading panel is
never announced. The collapsed trigger announces its expansion state via stateDescription
("Expanded" / "Collapsed"); Compose currently has no localizable expanded-state semantic property,
so these strings are supplied by the library in English.
[!TIP] Animation & State Handoff in Debug Builds
Due to Compose runtime overhead and unoptimized recompositions in Debug builds, the 1-frame transition/handoff interval between the collapsed anchor and the morphing surface may occasionally be amplified.
In Release builds (with R8 / compiler optimizations enabled), this handoff is seamless and transitions run smoothly at full frame rate. Always evaluate animation fluidity on Release builds.
Copyright 2026 Viel
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.