Pitfalls
Every "In practice" note in one place: API details of Material 3 Expressive that the guidelines do not cover.
- Interactive page: https://m3e.ljzapps.app/#foundations/pitfalls
Every "In practice" note from the component pages in one list. These are details of the Compose API, not design rules.
Buttons
- **Only the shapes overload morphs.** Button has two overloads. The one with shape: Shape keeps a static shape. The press morph needs the overload with shapes: ButtonShapes, for example shapes = ButtonDefaults.shapes(). That parameter has no default, so you only get the morph if you pass it.
- **Size is not one parameter.** There is no size argument. A medium or large button combines Modifier.heightIn(min = ButtonDefaults.MediumContainerHeight) with the matching ButtonDefaults.shapesFor(height), contentPaddingFor(height), textStyleFor(height) and, with an icon, iconSizeFor(height) and iconSpacingFor(height). If you change only the height, padding and type stay small.
- **No opt-in in current versions.** Since Material3 1.12.0-alpha03 (Jetpack 1.5.0-alpha22) the shapes overloads are stable. Older alphas marked them @ExperimentalMaterial3ExpressiveApi, so code pinned to an older version still needs the opt-in.
Full page: /docs/components/button.md
Button group
- **The content is a DSL, not a composable.** ButtonGroup takes content: ButtonGroupScope.() -> Unit. You add items with clickableItem, toggleableItem or customItem, so the group can measure them and move what does not fit into the overflow menu. Calling Button directly inside the lambda does not compile.
- **overflowIndicator is required.** The first parameter has no default. Pass ButtonGroupDefaults.OverflowIndicator(menuState) unless you have your own trigger. It only appears when the items do not fit.
- **Custom items do not expand by themselves.** In customItem, the press expand and neighbor squish only work if the button uses Modifier.animateWidth(interactionSource) with the same MutableInteractionSource the button receives. Set expandedRatio = 0f on the group to switch the effect off.
Full page: /docs/components/button-group.md
Connected button group
- **There is no ConnectedButtonGroup composable.** A connected group is a Row with horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) and ToggleButtons. The first one uses connectedLeadingButtonShapes(), the middle ones connectedMiddleButtonShapes() and the last one connectedTrailingButtonShapes().
- **You own the selection logic.** ToggleButton only reports onCheckedChange. Single select, multi select and keeping at least one item checked are your state code. For single select, also give each button Modifier.semantics { role = Role.RadioButton }, otherwise screen readers announce a set of independent toggles.
Full page: /docs/components/connected-button-group.md
Toggle button
- **Three shapes, not two.** ToggleButtonShapes holds shape, pressedShape and checkedShape. By default a checked toggle button turns from round to square, so if you pass custom shapes, set checkedShape too or the checked state loses its visual cue.
- **Size follows the same pattern as Button.** The default shapes are ToggleButtonDefaults.shapesFor(ButtonDefaults.MinHeight). For bigger toggle buttons, change the height modifier, the shapes and the content padding together.
Full page: /docs/components/toggle-button.md
Split button
- **The trailing button does not open anything.** SplitButtonDefaults.TrailingButton only takes checked and onCheckedChange and morphs its corners. It neither shows a menu nor rotates the chevron. Bind checked to the expanded state of your DropdownMenu and rotate the icon yourself, for example with graphicsLayer.
- **Match both halves to the same height.** Leading and trailing button each have their own shapes and padding helpers, such as leadingButtonShapesFor(height) and trailingButtonShapesFor(height). Use the same height for both, or the halves end up with different corner radii.
- **Touch target is enforced on the layout.** SplitButtonLayout sets the minimum interactive size of its children to 0 dp and enforces it on the parent instead. Do not add your own minimumInteractiveComponentSize to the halves.
Full page: /docs/components/split-button.md
Icon button
- **Morphing needs the shapes overload.** As with Button, only the overload with shapes: IconButtonShapes animates on press. Pass IconButtonDefaults.shapes() to get the expressive default.
- **contentDescription is not optional in practice.** Icon buttons have no visible label, so the Icon inside needs a contentDescription that names the action, for example "Add to favorites", not the icon's name.
Full page: /docs/components/icon-button.md
Floating action button
- **Hiding only changes how it is drawn.** Modifier.animateFloatingActionButton scales and fades the FAB, it keeps its place in the layout and in the semantics tree. If a hidden FAB must not be reachable, also stop handling its clicks or remove it after the animation.
- **Put the modifier on the FAB itself.** Apply animateFloatingActionButton to the FloatingActionButton (or its extended and sized variants), not to a wrapper, so the scale origin given by alignment matches the button's bounds.
- **Sizes and shapes are separate composables.** Medium and Large FABs are their own composables (MediumFloatingActionButton, LargeFloatingActionButton). Changing the size modifier of a regular FAB keeps the regular corner radius and icon size.
Full page: /docs/components/fab.md
FAB menu
- **No scrim, no back handling, no auto close.** FloatingActionButtonMenu only animates its items based on expanded. Closing the menu when an item is clicked, on back and on a tap outside is your code. Set expanded = false in every item's onClick.
- **Animate the icon with checkedProgress.** Inside ToggleFloatingActionButton, the content lambda gets checkedProgress. Use it to cross fade or rotate between the add and close icons so the icon follows the container morph.
Full page: /docs/components/fab-menu.md
Loading indicator
- **Custom polygons need at least two shapes.** polygons expects at least two RoundedPolygons and throws an IllegalArgumentException with fewer. The indeterminate and determinate variants have different defaults: IndeterminateIndicatorPolygons and DeterminateIndicatorPolygons.
- **It keeps the frame loop busy.** The indeterminate indicator animates forever. Remove it from composition once loading finishes instead of hiding it with alpha, otherwise it keeps rendering frames. That matters on Wear OS, low end phones and the web.
- **Pull to refresh has a ready made version.** PullToRefreshDefaults.LoadingIndicator drops into the indicator slot of PullToRefreshBox, so you do not have to position a LoadingIndicator yourself.
Full page: /docs/components/loading-indicator.md
Progress indicator
- **The wave is flat at the start and end.** The default amplitude of the determinate wavy indicators is 0 at or below 10 percent and at or above 95 percent progress, and 1 in between. Do not be surprised if a test at progress 0.05 shows a straight line. Pass amplitude = { 1f } if you want the wave all the time.
- **Speed is tied to wavelength.** waveSpeed defaults to wavelength, which means one wavelength per second. If you change wavelength, the wave also moves faster or slower unless you set waveSpeed explicitly.
Full page: /docs/components/progress-indicator.md
Carousel
- **Use maskClip, not clip.** Carousel items change size while you scroll. Clip each item with Modifier.maskClip(shape) from the item scope, and use maskBorder for borders. A plain Modifier.clip clips the full item size, so the masking looks wrong.
- **Item count goes into the state.** rememberCarouselState { itemCount } takes the count as a lambda, and the item lambda only gets the index. Keep the list and the count in sync, for example rememberCarouselState { items.size }.
Full page: /docs/components/carousel.md
Navigation bar
- **ShortNavigationBar is the expressive bar.** NavigationBar and NavigationBarItem still exist and still look like baseline Material 3. The expressive bar is ShortNavigationBar with ShortNavigationBarItem. Migrating means changing both names, the item parameters stay similar.
- **Horizontal items on medium widths.** ShortNavigationBarItem has iconPosition. NavigationItemIconPosition.Top is the phone layout. On medium widths use NavigationItemIconPosition.Start with ShortNavigationBarArrangement.Centered so the items do not stretch across the whole width.
Full page: /docs/components/navigation-bar.md
Navigation rail
- **Items do not know the rail is expanded.** Each WideNavigationRailItem takes railExpanded, and the rail does not pass it down. Compute it from the state, for example state.targetValue == WideNavigationRailValue.Expanded, and give it to every item. Otherwise the items keep the collapsed layout inside an expanded rail.
- **Expanding is a suspend call.** rememberWideNavigationRailState() exposes expand(), collapse() and toggle() as suspend functions. Call them from rememberCoroutineScope().launch in the menu button's onClick.
Full page: /docs/components/navigation-rail.md
Top app bar
- **Flexible bars need a scroll behavior to collapse.** MediumFlexibleTopAppBar and LargeFlexibleTopAppBar only collapse if you pass scrollBehavior and also add Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) to the scrolling container, typically the Scaffold.
- **A subtitle changes the expanded height.** The default expandedHeight depends on whether you pass a subtitle, for example LargeFlexibleAppBarWithSubtitleExpandedHeight vs. LargeFlexibleAppBarWithoutSubtitleExpandedHeight. If you set expandedHeight yourself, account for the subtitle.
- **Classic bars are stable, the new slots are not.** TopAppBar, CenterAlignedTopAppBar, MediumTopAppBar and LargeTopAppBar are stable. The TopAppBar overload with subtitle, the flexible bars and TwoRowsTopAppBar need @OptIn(ExperimentalMaterial3ExpressiveApi::class).
- **In the small bar, subtitle is required.** The subtitle parameter of the expressive TopAppBar overload has no default, that is how the overload is picked. Without a subtitle you get the classic overload, which has no titleHorizontalAlignment either.
- **TwoRowsTopAppBar adds no bottom padding.** Unlike the medium and large bars, it does not pad the expanded title. Add padding to the expanded title or subtitle yourself, and keep expandedHeight at least collapsedHeight, otherwise it throws.
Full page: /docs/components/top-app-bar.md
Floating toolbar
- **expanded only affects the leading and trailing slots.** The main content is always visible. leadingContent and trailingContent are shown only while expanded is true. For a toolbar that scrolls away completely, use a scroll behavior instead.
- **The scroll behavior is a NestedScrollConnection.** FloatingToolbarDefaults.exitAlwaysScrollBehavior(exitDirection) returns an object that implements NestedScrollConnection. Pass it to the toolbar and attach it with Modifier.nestedScroll(behavior) to the scrolling content, or nothing happens.
- **Scrolling is ignored with TalkBack.** The toolbar does not react to scrolling while a touch exploration service is active, so it stays reachable. Do not build logic that assumes the toolbar has hidden.
Full page: /docs/components/floating-toolbar.md
Search
- **Two opt-ins for the new variants.** All search bars need @OptIn(ExperimentalMaterial3Api::class). ExpandedDockedSearchBarWithGap and ExpandedFullScreenContainedSearchBar also need @OptIn(ExperimentalMaterial3ExpressiveApi::class).
- **Collapsed and expanded are two composables.** The collapsed bar (SearchBar or AppBarWithSearch) and the expanded bar are separate calls that share one SearchBarState and the same inputField lambda. Leaving out the collapsed one shows nothing until the state expands.
- **Pick the variant by window size.** The docked variants are meant for medium and expanded widths, the full screen variants for phones. Switch with the window size class instead of shipping one variant everywhere.
- **The full screen variants open a dialog.** ExpandedFullScreenSearchBar and ExpandedFullScreenContainedSearchBar render in a new full screen dialog window, so they cover the whole window, not just the area of your layout.
Full page: /docs/components/search.md
Slider
- **The new tracks are still experimental.** Slider itself is stable. SliderDefaults.CenteredTrack, the Track overload with trackCornerSize and the Thumb overload that takes sliderState need @OptIn(ExperimentalMaterial3ExpressiveApi::class).
- **The default track does not shrink its corners.** Slider(state) uses a track without corner shrinking. The expressive look needs the Track overload with trackCornerSize, passed through the track slot.
- **Track height comes from the modifier.** There is no height parameter. Modifier.height(40.dp) on the Track wins over the built in 16 dp because it comes first in the chain. Make the thumb taller with thumbSize, or it ends up shorter than the track.
- **Centered needs a range around zero.** CenteredTrack fills from the middle of the track, not from zero. With valueRange = 0f..100f the middle is 50. Use a symmetric range such as -50f..50f.
- **A changed range or step count needs a new state.** rememberSliderState is saveable and keeps its first range and steps. Wrap it in key(steps, range) or recreate the state when they change.
Full page: /docs/components/slider.md
Chips
- **Only three chips can morph.** The shapes: ChipShapes overload exists for FilterChip, ElevatedFilterChip and InputChip. AssistChip and SuggestionChip only take a static shape.
- **The shapes overload also changes colors and spacing.** Besides the morph, it defaults to tonal colors (FilterChipDefaults.tonalFilterChipColors(), InputChipDefaults.tonalInputChipColors()) and to a tighter 4 dp gap next to icons. Switching one chip in a row to the new overload makes it look different from its neighbors, so switch the whole row.
- **Default shapes: medium, full, small.** FilterChipDefaults.shapes() rests at medium corners, becomes a pill when selected and uses small corners while pressed. Override single values with shapes(shape = ..., selectedShape = ..., pressedShape = ...).
- **No opt-in needed.** The chip shapes overloads are stable in this version, like most Expressive components. Each component page shows its status in the badge below the category.
- **The check mark is yours.** A selected FilterChip does not add a check icon by itself. Swap leadingIcon to a check while selected, otherwise the selection depends on color alone.
Full page: /docs/components/chips.md
Menu
- **Groups need a spacer between them.** DropdownMenuPopup lays its content out in a plain Column. Without Spacer(Modifier.height(MenuDefaults.GroupSpacing)) between two DropdownMenuGroups, the groups touch and the segmented look is lost.
- **Count per group, not per menu.** MenuDefaults.itemShape(index, count) expects the index and the number of items inside the current group. Passing the position in the whole menu gives the wrong corners. groupShape(index, count) counts groups.
- **shape for actions, shapes for choices.** The plain action item takes shape: Shape, so pass MenuDefaults.itemShape(i, n).shape. The selectable and checkable items take shapes: MenuItemShapes, which also hold the selected shape they morph to.
- **The overload decides the semantics.** The item with selected and onClick is announced as a radio button, the one with checked and onCheckedChange as a checkbox, and the plain one as a button. Pick the overload by meaning, not by look.
- **Selecting does not close the menu.** No DropdownMenuItem closes the popup by itself. For actions set expanded = false in onClick. For selections it is often better to keep the menu open so the user sees the result.
- **Submenus anchor to their parent layout.** A cascading menu is a second DropdownMenuPopup with MenuAnchorPosition.End. Put it in a Box together with the item that opens it, otherwise it opens next to the whole menu. Use PopupProperties(focusable = false) so the parent menu keeps focus.
Full page: /docs/components/menu.md
List
- **The headline moves to the trailing lambda.** The classic ListItem takes headlineContent. The interactive overloads (onClick, selected, checked) have no headlineContent parameter: the headline is the trailing content lambda.
- **Only the classic ListItem is stable.** ListItem(headlineContent = ...) needs no opt-in. The overloads with onClick, selected or checked, SegmentedListItem and ListItemDefaults.shapes() need @OptIn(ExperimentalMaterial3ExpressiveApi::class).
- **Do not wrap an interactive item in clickable.** The interactive overloads handle click, long click, ripple and the shape morph themselves. Adding Modifier.clickable creates a second touch target and loses the morph.
- **Segmented items need the gap.** SegmentedListItem only gets its look with Arrangement.spacedBy(ListItemDefaults.SegmentedGap) in the parent and segmentedShapes(index, count) per item, where count is the size of the whole group.
Full page: /docs/components/list.md
Scroll field
- **Item height is fixed.** Each item is a third of ScrollFieldDefaults.ScrollFieldHeight (200 dp) tall, whatever height you give the field. Use that height to see one item above and one below the selection. A smaller field only cuts the neighbors off.
- **No accessibility parameters in this version.** ScrollField in 1.12.0-alpha03 takes no contentDescription. Add Modifier.semantics with a contentDescription ("Hour") and a stateDescription with the current value, otherwise a screen reader only hears the numbers. Newer alphas add parameters for this.
- **It always wraps around.** The wheel is a pager with a very large page count that starts in the middle, so it never reaches an end. There is no switch for a bounded list.
- **Changing the count needs a new state.** rememberScrollFieldState(itemCount, index) is keyed on both values. If the count changes, for example days in a month, the wheel jumps to the initial index again. Keep the selected value yourself and pass it as index.
- **The default item pads with a zero.** ScrollFieldDefaults.Item shows 7 as 07. For values that are not indices, or that should not be padded, pass your own field lambda.
Full page: /docs/components/scroll-field.md