Android Platform (Jetpack Compose / Material 3 Expressive): Typography, Shape & Motion
Self-contained. The skill’s rules, then this topic, then anything it relies on from other skills.
Skill rules
Current stable Android. Kotlin. Compose only. Material 3 Expressive (M3E) is an evolution of Material You, not a replacement: spring motion, emphasized type, shape morphing, and a revised component set on the same token system. Motion, type, and shape work together. One without the others looks like M3E but doesn’t feel like it. Support dynamic color, with a brand palette as the fallback.
Design values follow m3.material.io. API names follow AndroidX. Load only the reference file the task needs.
| Topic |
Reference |
| Gradle setup, theme wiring, color roles, dynamic color, contrast, elevation |
reference/theme-and-color.md |
Type scale, Roboto Flex, shape scale, shape morphing, MotionScheme, reduce motion |
reference/typography-shape-motion.md |
| Spacing, M3E components, insets, adaptive layout, Navigation 3, predictive back |
reference/components-and-navigation.md |
| Ripple, state layers, haptics, touch targets, semantics, contrast |
reference/interaction-and-accessibility.md |
Canvas, mesh gradients, AGSL, RenderEffect, blur, rich text |
reference/custom-rendering.md |
Version Snapshot
As of 2026-11-10. This is the only place the skill pins versions. Before pinning anything, check the BOM mapping and the material3 release notes. If they disagree with this table, they win.
| Thing |
Current |
| Platform |
Android 17, API 37. Google Play requires targetSdk 36+ |
| Kotlin |
2.4.21 |
| Compose BOM |
2026.09.00: Compose 1.12.1, material3 1.4.0, material3-adaptive-navigation-suite 1.4.0, adaptive 1.3.0 |
material3 with M3E |
1.5.0-rc01, in no BOM yet, built against Compose 1.12 |
| Navigation 3 |
1.2.1 (navigation-compose 2.10.2 still maintained) |
| Other |
navigationevent 1.2.0, activity 1.13.0, window 1.5.1, graphics-shapes 1.1.0 |
M3E needs material3 1.5.0. That’s a release candidate, and it isn’t in the stable BOM. material3 1.4.0 has no M3E API at all, not even behind an opt-in. Getting M3E means pinning 1.5.0 on top of the BOM (see theme-and-color.md). That’s a decision. Raise it before building anything.
- In 1.4.0 (plain BOM): baseline M3 components,
ShortNavigationBar, WideNavigationRail, NavigationSuiteScaffold, surface container and fixed color roles, ripple().
- 1.5.0, stable:
MaterialExpressiveTheme, expressiveLightColorScheme(), MotionScheme / MaterialTheme.motionScheme, emphasized type styles (titleLargeEmphasized…), Typography(fontFamily), shape tokens largeIncreased / extraLargeIncreased / extraExtraLarge, ButtonShapes and the shapes parameter on buttons, ToggleButton, ButtonGroup, SplitButtonLayout, HorizontalFloatingToolbar / VerticalFloatingToolbar, FloatingActionButtonMenu, Modifier.animateFloatingActionButton, LinearWavyProgressIndicator / CircularWavyProgressIndicator, MediumFlexibleTopAppBar / LargeFlexibleTopAppBar.
- 1.5.0,
@OptIn(ExperimentalMaterial3ExpressiveApi::class): LoadingIndicator, ContainedLoadingIndicator, MaterialShapes, RoundedPolygon.toShape().
- Don’t exist:
expressiveDarkColorScheme(), a composable named SplitButton, a plain FloatingToolbar.
By targetSdk: 35 enforces edge-to-edge. 36 removes the opt-out, turns predictive back on (onBackPressed and KEYCODE_BACK are no longer delivered), and ignores orientation and resizability locks on displays 600dp and wider. 37 removes the opt-out for that last one.
Platform Visual Signature
A correct Android / M3E design looks like Google made it. If it reads as iOS or as a website, it’s wrong.
- Navigation bar at the bottom with 3-5 labeled destinations on compact widths. Navigation rail (3-7 destinations, optional FAB) on wider windows
- Edge-to-edge: system bars are transparent and content draws behind them
- Dynamic color: the primary hue changes per device
- Roboto Flex or Roboto throughout. Not SF Pro, not Inter. Roboto is the M3 default. I use Roboto Flex for its variable axes
- FAB for the screen’s most important action, lower right on compact and medium, in the rail on expanded
- Large flexible top app bar that collapses on scroll, not a static header
- Moderate, consistent rounding from the shape scale (4, 8, 12, 16, 28dp). Not sharp, not everything a pill
- Ripple on every interactive surface
M3E replaced several components. Use the right-hand column in new work.
| No longer recommended |
Use |
| Baseline navigation bar (80dp) |
Flexible navigation bar, 64dp (ShortNavigationBar) |
| Baseline navigation rail (80dp) |
Collapsed / expanded navigation rail, 96dp collapsed (WideNavigationRail) |
| Navigation drawer |
Expanded navigation rail |
| Medium / large top app bar |
Medium flexible (112dp) / large flexible (120dp) |
| Bottom app bar |
Docked toolbar |
| Segmented button |
Connected button group |
| Small FAB, speed dial, stacked FABs |
FAB / medium FAB / large FAB, FAB menu |
Figma frame, phone (412 × 915, compact):
Frame: 412 × 915
├── Status Bar (transparent, content shows through)
├── Large flexible top app bar (120px, 152px with subtitle, collapses to 64px)
├── Content Area (extends behind both system bars, 16px side margins)
├── Navigation bar (64px)
└── System Navigation (gesture bar or 3-button)
Figma frame, tablet / unfolded foldable (840 × 1080, expanded):
Frame: 840 × 1080
├── Status Bar
├── Navigation rail (96px wide collapsed, leading edge, full height)
└── Content Area: two panes, 24px margins, 24px spacer between panes
System bar heights depend on the device and aren’t in the Material spec. Take them from the Android UI kit in use.
Wrong if any signature item above is missing, or:
- Primary navigation is a row of tabs at the top
- Top-level destinations are reached by pushing screens instead of switching bar / rail destinations
- A navigation bar and a toolbar are on screen together
- A phone layout is stretched across a tablet
Standing Rules
Every rule has a reason. If the reason doesn’t apply, say so and proceed. Don’t drop a rule silently.
- Compose only. I don’t use XML layouts or Fragments in new code.
AndroidView is for views with no Compose equivalent (maps, WebView, camera preview).
- No colors outside the theme. A
Color(0xFF...) in a composable bypasses dynamic color, dark theme, and contrast levels at once.
- No hardcoded dp/sp in composables. Spacing, shape, type, and motion come from
MaterialTheme or token objects.
- Edge-to-edge and every window size. The platform enforces both on current targets.
- Springs for user-triggered motion, from
MaterialTheme.motionScheme. Duration-based tween only for system-triggered motion with no gesture behind it. Never linear for a UI transition. Token names beyond the scheme come from the motion-design skill.
- Indeterminate waits of 200ms to 5s use
LoadingIndicator. Over 5s, a progress indicator. Under 200ms, nothing.
- No deprecated APIs when a replacement exists:
rememberRipple() (use ripple()), calculateWindowSizeClass() and currentWindowAdaptiveInfo() (use currentWindowAdaptiveInfoV2()), onBackPressed(), Accompanist system UI controller, ClickableText, and from material3 1.5.0 the Slider(value, onValueChange) overloads (use Slider(state, onValueChange) with a SliderState).
- I build custom. When stock doesn’t match the design, build the component. It still honors 48dp targets, semantics, ripple, theme tokens, and the motion scheme.
- Measure before optimizing. Recomposition counts in Layout Inspector, release build, mid-tier hardware.
Topic
Material’s own values. Which APIs need material3 1.5.0 is in the Version Snapshot in SKILL.md. Motion token names, Compose animation technique, variable-axis animation, and reduce-motion handling belong to the motion-design skill (reference/cross-platform-tokens.md, reference/android-compose.md).
Contents
Type Scale
One scale, 30 styles: 15 baseline and 15 emphasized. Emphasized styles keep size and line height and raise the weight. Components don’t use them by default. Swap one in for selected items, unread messages, primary-action buttons, badges, and editorial headlines. No product uses all 30. Pick the subset the design needs.
| Style |
Size / line height (sp) |
Weight |
Tracking (sp) |
Emphasized weight |
| displayLarge |
57 / 64 |
400 |
-0.25 |
500 |
| displayMedium |
45 / 52 |
400 |
0 |
500 |
| displaySmall |
36 / 44 |
400 |
0 |
500 |
| headlineLarge |
32 / 40 |
400 |
0 |
500 |
| headlineMedium |
28 / 36 |
400 |
0 |
500 |
| headlineSmall |
24 / 32 |
400 |
0 |
500 |
| titleLarge |
22 / 28 |
400 |
0 |
500 |
| titleMedium |
16 / 24 |
500 |
0.15 |
700 |
| titleSmall |
14 / 20 |
500 |
0.1 |
700 |
| bodyLarge |
16 / 24 |
400 |
0.5 |
500 |
| bodyMedium |
14 / 20 |
400 |
0.25 |
500 |
| bodySmall |
12 / 16 |
400 |
0.4 |
500 |
| labelLarge |
14 / 20 |
500 |
0.1 |
700 |
| labelMedium |
12 / 16 |
500 |
0.5 |
700 |
| labelSmall |
11 / 16 |
500 |
0.5 |
700 |
Design files use this table. Code uses Typography() as it ships. The library rounds three trackings (displayLarge -0.2, titleMedium 0.2, bodyMedium 0.2), so code can sit a few hundredths of an sp off the table. Don’t override to compensate.
When customizing, change typeface, line height, and tracking. Don’t change sizes. Components are laid out around them.
Typography Setup
// material3 1.5.0: one family across all 30 styles
val AppTypography = Typography(fontFamily = RobotoFlex)
// 1.4.0, or per-style changes: start from the defaults
private val Default = Typography()
val AppTypographyCustom = Default.copy(
displayLarge = Default.displayLarge.copy(fontFamily = RobotoFlex),
titleLarge = Default.titleLarge.copy(fontFamily = RobotoFlex)
// remaining styles
)
Sizes and line heights are sp. Composables take styles from MaterialTheme.typography (titleLargeEmphasized for the emphasized twin). Android scales fonts non-linearly up to 200%, so no fixed-height text containers. Scripts taller than Latin need more line height (most non-Latin scripts about 7%, Burmese and Telugu about 30%). Containers sized by padding adapt. Fixed heights don’t.
Fonts
Roboto is the Material default and what Typography() uses. Roboto Flex isn’t part of the M3 type scale, so using it means bundling it. I use it for its variable axes. It covers only Latin, Greek, and Cyrillic (about 900 glyphs), so it has to fall back to the system font for other scripts.
object TypeAxis {
const val WeightRest = 400
const val WidthDefault = 100f
}
fun robotoFlex(weight: Int, width: Float = TypeAxis.WidthDefault): FontFamily = FontFamily(
Font(
resId = R.font.roboto_flex,
weight = FontWeight(weight),
variationSettings = FontVariation.Settings(
FontWeight(weight),
FontStyle.Normal,
FontVariation.width(width)
)
)
)
val RobotoFlex = robotoFlex(TypeAxis.WeightRest)
Axis values are tokens, not numbers in composables. Animating an axis on interaction is in motion-design’s android-compose.md.
Shape Scale
A ten-step scale named for roundedness. Components map to a step, so changing a step restyles every component on it.
| Step |
Radius |
MaterialTheme.shapes |
Typical components |
| None |
0dp |
RectangleShape |
Full-width bars |
| Extra small |
4dp |
extraSmall |
Text field tops, menus, snackbars |
| Small |
8dp |
small |
Chips |
| Medium |
12dp |
medium |
Cards |
| Large |
16dp |
large |
FAB |
| Large increased |
20dp |
largeIncreased |
M3E |
| Extra large |
28dp |
extraLarge |
Dialogs, bottom sheet top, large FAB |
| Extra large increased |
32dp |
extraLargeIncreased |
M3E |
| Extra extra large |
48dp |
extraExtraLarge |
M3E |
| Full |
fully rounded |
CircleShape |
Buttons, badges |
Shapes() already carries these. Declare AppShapes only for the steps the design changes. Closely grouped items (menus, split buttons, connected button groups) use asymmetric shapes with smaller inner corners from the same scale.
The Material Shape Library adds 35 named polygons (MaterialShapes.Cookie9Sided, MaterialShapes.Clover4Leaf, …) in Figma and Compose. They all morph into each other. Use them for avatars, image masks, and hero containers.
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
Modifier.clip(MaterialShapes.Cookie9Sided.toShape())
Shape Morphing
Shape morphing is a state signal (pressed, selected, loading). It isn’t decoration. Material uses it in the standard button group and the loading indicator. It runs on the expressive motion scheme by default.
Stock components already morph. M3E buttons take a shape pair and animate between them on press.
Button(onClick = onSave, shapes = ButtonDefaults.shapes()) { Text("Save") }
Button(
onClick = onSave,
shapes = ButtonDefaults.shapes(shape = CircleShape, pressedShape = MaterialTheme.shapes.small)
) { Text("Save") }
IconButton and FilledIconButton take IconButtonDefaults.shapes(). ToggleButton takes ToggleButtonShapes, which adds a checked shape.
Custom morphs use Morph from androidx.graphics:graphics-shapes. Build the polygons at the drawn size inside drawWithCache and drive progress from a spatial spec.
@Composable
fun MorphingBadge(selected: Boolean, modifier: Modifier = Modifier) {
val progress by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(),
label = "badgeMorph"
)
val color = MaterialTheme.colorScheme.primaryContainer
Box(
modifier.drawWithCache {
val radius = size.minDimension / 2f
val cx = size.width / 2f
val cy = size.height / 2f
val morph = Morph(
start = RoundedPolygon.circle(numVertices = 8, radius = radius, centerX = cx, centerY = cy),
end = RoundedPolygon.star(
numVerticesPerRadius = 8,
radius = radius,
innerRadius = radius * 0.8f, // illustrative; take from the design
rounding = CornerRounding(radius * 0.2f),
centerX = cx,
centerY = cy
)
)
onDrawBehind {
drawPath(morph.toPath(progress = progress).asComposePath(), color)
}
}
)
}
Motion Scheme
M3E replaced easing-and-duration motion with springs. The old easing, duration, and path tokens are legacy. Ignore them.
A MotionScheme is set once in the theme and hands out six specs: two types at three speeds.
- Spatial specs animate anything that moves: position, size, rotation, corner radius. They overshoot and settle.
- Effects specs animate color and opacity. They never overshoot.
- Speed:
default for most motion and for elements that partly cover the screen (bottom sheet, expanding rail). fast for small components (buttons, switches). slow for full-screen transitions.
Two schemes. Expressive overshoots to add bounce, and it’s Material’s recommendation for most products. Standard eases in with minimal bounce, for utilitarian products. A product runs on one scheme. Switch to the other only for a specific moment the design calls out. Material’s own components run on two specs: fast spatial and fast effects.
val motion = MaterialTheme.motionScheme
val offset by animateDpAsState(
targetValue = if (expanded) Spacing.s7 else Spacing.none,
animationSpec = motion.defaultSpatialSpec(),
label = "panelOffset"
)
val alpha by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = motion.defaultEffectsSpec(),
label = "panelAlpha"
)
Pair the spec with the property. A spatial spec on alpha or color overshoots past the target and flickers. It’s the most common M3E motion bug.
Spring values (damping ratio / stiffness) on phones. Don’t copy them into code. Read the scheme. They’re here for specs, Figma prototypes, and review.
| Spec |
Expressive |
Standard |
Approx. duration |
| Fast spatial |
0.6 / 800 |
0.9 / 1400 |
350ms |
| Default spatial |
0.8 / 380 |
0.9 / 700 |
500ms |
| Slow spatial |
0.8 / 200 |
0.9 / 300 |
650ms (standard 750ms) |
| Fast effects |
1.0 / 3800 |
1.0 / 3800 |
150ms |
| Default effects |
1.0 / 1600 |
1.0 / 1600 |
200ms |
| Slow effects |
1.0 / 800 |
1.0 / 800 |
300ms |
Durations are the spring-to-curve equivalents for platforms without springs. Token values differ on wearables and tablets, which is one more reason to read the scheme.
When a design needs motion the scheme doesn’t provide, or a system-triggered tween, use the token names from motion-design. Don’t define a second motion object here. If motion isn’t specified, ask.
Reduce motion: “Remove animations” sets the animator duration scale to 0, and Compose animations already honor it. Ambient loops and choreography still need an explicit check. See Reduce Motion in motion-design’s android-compose.md.
Shared definitions
What this file relies on from other skills, copied in so nothing needs looking up.
From motion-design: Motion Tokens
This is the one definition of the motion token names and their values on every platform. The other files in this skill, and the apple-platform, android-platform, web-platform, and visual-styles skills, use these names and don’t define their own. Change a value here and nowhere else.
Three families, matching the spring-vs-ease rule. Spring tokens for user-triggered motion. Duration and easing tokens, used together, for system-triggered motion. A project’s design files or existing token set override the values. The names stay. A visual style can declare extra tokens in its own file under the same naming rule. A style-local token is a token, not an inline value.
Contents
Spring Tokens
One spring per token, written three ways. All three platforms run the same spring: mass 1, stiffness (2π / duration)², damping ratio 1 – bounce.
| Token |
Use |
Spring |
Swift |
Kotlin |
CSS |
| snappy |
Press states, toggles, controls |
0.25s, bounce 0.15 |
Motion.Spring.snappy |
Motion.Spring.snappy() |
var(--spring-snappy) over var(--spring-snappy-duration) |
| standard |
Default for interactive elements: cards, sheets, menus, navigation |
0.35s, bounce 0 |
Motion.Spring.standard |
Motion.Spring.standard() |
var(--spring-standard) over var(--spring-standard-duration) |
| expressive |
Visible bounce: FABs, notifications, moments meant to delight |
0.4s, bounce 0.3 |
Motion.Spring.expressive |
Motion.Spring.expressive() |
var(--spring-expressive) over var(--spring-expressive-duration) |
| dragRelease |
An element let go after a drag, settling with the gesture’s velocity |
0.3s, bounce 0.28 |
Motion.Spring.dragRelease |
Motion.Spring.dragRelease() |
none: CSS can’t inherit gesture velocity, so drag release on web is driven from JavaScript |
A spring’s duration is perceptual, not its settle time. The CSS --spring-*-duration values (350ms, 520ms, 660ms) are settle times, which is why they’re longer. Each CSS curve is only valid at its paired duration.
Android apps themed with Material 3 Expressive take component and component-like motion from MaterialTheme.motionScheme instead. See android-platform. Motion.Spring.*() is for custom motion the scheme doesn’t cover.
Duration Tokens
For system-triggered motion, always paired with an easing token. Springs don’t take a duration token.
| Token |
Value |
Use |
Swift |
Kotlin |
CSS |
| micro |
83ms |
Feedback that isn’t spring-driven: icon swaps, color flips |
Motion.Duration.micro |
Motion.Duration.micro |
var(--duration-micro) |
| fast |
150ms |
Small state changes, exits, fades |
Motion.Duration.fast |
Motion.Duration.fast |
var(--duration-fast) |
| standard |
250ms |
Most transitions: dropdown open, toast appear, content swap |
Motion.Duration.standard |
Motion.Duration.standard |
var(--duration-standard) |
| slow |
350ms |
Large layout shifts, full-screen transitions |
Motion.Duration.slow |
Motion.Duration.slow |
var(--duration-slow) |
| cinematic |
600ms |
Deliberate editorial moments, ambient loops |
Motion.Duration.cinematic |
Motion.Duration.cinematic |
var(--duration-cinematic) |
Nothing shorter than micro. Below that, make the change instant. Nothing longer than 700ms in product UI. If an animation needs longer to feel right, more motion isn’t the fix. Scroll-scrubbed and ambient WebGL sequences have no duration of their own, so they’re exempt.
Easing Tokens
| Token |
Curve |
Use |
Swift |
Kotlin |
CSS |
| enter |
0, 0, 0.2, 1 |
Entrances: decelerates as it arrives |
Motion.Curve.enter |
Motion.Ease.enter |
var(--ease-enter) |
| exit |
0.4, 0, 1, 1 |
Exits: accelerates as it leaves |
Motion.Curve.exit |
Motion.Ease.exit |
var(--ease-exit) |
| standard |
0.4, 0, 0.2, 1 |
State changes that stay on screen |
Motion.Curve.standard |
Motion.Ease.standard |
var(--ease-standard) |
| emphasized |
0.2, 0, 0, 1 |
Quick start, long settle: hero and container moves |
Motion.Curve.emphasized |
Motion.Ease.emphasized |
var(--ease-emphasized) |
| expo-out |
0.16, 1, 0.3, 1 |
Very fast start, very long tail: reveals |
Motion.Curve.expoOut |
Motion.Ease.expoOut |
var(--ease-expo-out) |
| sine-in-out |
0.45, 0.05, 0.55, 0.95 |
Gentle and symmetrical: slow, ambient |
Motion.Curve.sineInOut |
Motion.Ease.sineInOut |
var(--ease-sine-in-out) |
Linear isn’t a token. It’s only right for continuous loops and scroll-linked motion.
Swift is the one exception to the naming rule. SwiftUI animations carry their duration, so motion/ease/<name> becomes two things: Motion.Curve.<name> is the bare curve, and Motion.Ease.<name> is the ready-to-use Animation, the curve at its default duration. The pairs are Motion.Ease.enter (enter, standard), exit (exit, fast), standard (standard, standard), emphasized (emphasized, slow), expoOut (expo-out, cinematic), fade (enter, fast), and ambient (sine-in-out, cinematic). In Kotlin Motion.Ease.enter, exit, and standard alias Compose’s LinearOutSlowInEasing, FastOutLinearInEasing, and FastOutSlowInEasing. Call sites use the token name.
Definitions per Platform
One definition site per platform. Call sites reference the token, never a raw curve, duration, or spring constant.
:root {
--duration-micro: 83ms;
--duration-fast: 150ms;
--duration-standard: 250ms;
--duration-slow: 350ms;
--duration-cinematic: 600ms;
--ease-enter: cubic-bezier(0, 0, 0.2, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-expo-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-sine-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
/* Springs as linear() stops: mass 1, stiffness (2π / duration)²,
damping ratio 1 - bounce, sampled to the point the spring settles
within 0.1%. */
/* snappy: duration 0.25s, bounce 0.15 (stiffness 632, damping ratio 0.85) */
--spring-snappy: linear(0, 0.006 1.2%, 0.026 2.8%, 0.057 4.2%, 0.096 5.8%, 0.191 8.8%, 0.413 15%, 0.514 18%, 0.613 21.2%, 0.698 24.5%, 0.765 27.5%, 0.825 30.8%, 0.873 34%, 0.914 37.5%, 0.946 41.2%, 0.971 45.5%, 0.989 50.2%, 1 55.8%, 1.006 67.8%, 1);
--spring-snappy-duration: 350ms;
/* standard: duration 0.35s, bounce 0 (stiffness 322, damping ratio 1) */
--spring-standard: linear(0, 0.004 1%, 0.019 2.2%, 0.043 3.5%, 0.08 5%, 0.156 7.5%, 0.367 13.8%, 0.463 16.8%, 0.557 20%, 0.638 23.2%, 0.707 26.5%, 0.769 30%, 0.819 33.5%, 0.862 37.2%, 0.901 41.8%, 0.93 46.5%, 0.954 52%, 0.972 58.2%, 0.984 65.2%, 0.992 73.5%, 1);
--spring-standard-duration: 520ms;
/* expressive: duration 0.4s, bounce 0.3 (stiffness 247, damping ratio 0.7), 4.6% overshoot */
--spring-expressive: linear(0, 0.008 1.2%, 0.03 2.5%, 0.063 3.8%, 0.114 5.2%, 0.218 7.8%, 0.476 13.2%, 0.587 15.8%, 0.697 18.5%, 0.783 21%, 0.855 23.5%, 0.918 26.2%, 0.966 29%, 1.003 32%, 1.022 34.2%, 1.036 36.8%, 1.044 39.5%, 1.046 42.5%, 1.041 47.8%, 1.012 62.3%, 1.003 70%, 0.998 81.8%, 1);
--spring-expressive-duration: 660ms;
--motion-distance-sm: 12px;
--motion-distance-md: 24px;
}
import SwiftUI
enum Motion {
enum Duration {
static let micro: TimeInterval = 0.083
static let fast: TimeInterval = 0.15
static let standard: TimeInterval = 0.25
static let slow: TimeInterval = 0.35
static let cinematic: TimeInterval = 0.6
}
enum Curve {
static let enter = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0, y: 0), endControlPoint: UnitPoint(x: 0.2, y: 1))
static let exit = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.4, y: 0), endControlPoint: UnitPoint(x: 1, y: 1))
static let standard = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.4, y: 0), endControlPoint: UnitPoint(x: 0.2, y: 1))
static let emphasized = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.2, y: 0), endControlPoint: UnitPoint(x: 0, y: 1))
static let expoOut = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.16, y: 1), endControlPoint: UnitPoint(x: 0.3, y: 1))
static let sineInOut = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.45, y: 0.05), endControlPoint: UnitPoint(x: 0.55, y: 0.95))
}
enum Spring {
static let snappy = Animation.snappy(duration: 0.25) // bounce 0.15
static let standard = Animation.spring(duration: 0.35, bounce: 0)
static let expressive = Animation.spring(duration: 0.4, bounce: 0.3)
static let dragRelease = Animation.spring(duration: 0.3, bounce: 0.28)
}
enum Ease {
static let enter = Animation.timingCurve(Curve.enter, duration: Duration.standard)
static let exit = Animation.timingCurve(Curve.exit, duration: Duration.fast)
static let standard = Animation.timingCurve(Curve.standard, duration: Duration.standard)
static let emphasized = Animation.timingCurve(Curve.emphasized, duration: Duration.slow)
static let expoOut = Animation.timingCurve(Curve.expoOut, duration: Duration.cinematic)
static let fade = Animation.timingCurve(Curve.enter, duration: Duration.fast)
static let ambient = Animation.timingCurve(Curve.sineInOut, duration: Duration.cinematic)
}
}
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
object Motion {
object Spring {
fun <T> snappy(): SpringSpec<T> = spring(dampingRatio = 0.85f, stiffness = 632f)
fun <T> standard(): SpringSpec<T> = spring(dampingRatio = 1f, stiffness = 322f)
fun <T> expressive(): SpringSpec<T> = spring(dampingRatio = 0.7f, stiffness = 247f)
fun <T> dragRelease(): SpringSpec<T> = spring(dampingRatio = 0.72f, stiffness = 439f)
}
object Duration {
const val micro = 83
const val fast = 150
const val standard = 250
const val slow = 350
const val cinematic = 600
}
object Ease {
val enter: Easing = LinearOutSlowInEasing
val exit: Easing = FastOutLinearInEasing
val standard: Easing = FastOutSlowInEasing
val emphasized: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val expoOut: Easing = CubicBezierEasing(0.16f, 1f, 0.3f, 1f)
val sineInOut: Easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
}
}
Usage:
.button { transition: scale var(--spring-snappy-duration) var(--spring-snappy); }
.toast { transition: opacity var(--duration-standard) var(--ease-enter); }
withAnimation(Motion.Spring.standard) { isExpanded.toggle() }
animateFloatAsState(target, animationSpec = Motion.Spring.snappy(), label = "scale")
tween<Float>(durationMillis = Motion.Duration.standard, easing = Motion.Ease.enter)
GSAP registers the easing tokens under the same names ("enter", "expo-out", and so on). See gsap.md.
Reduced Motion Policy
The policy lives here. Each platform file only shows the API that reads the setting. The reasons are in foundations.md.
When the user asks for reduced motion:
- Spatial motion becomes a fade. Replace slides, zooms, scale, and shared-element travel with an opacity change at fast with enter (
Motion.Ease.fade), or an instant change.
- Springs lose their bounce. Where a spring stays (a control following a finger), use standard, never expressive.
- Scroll-linked motion goes. No parallax, no scroll-driven transforms, no pinned or scrubbed sequences. Content sits in its final position. Smooth scrolling is off.
- Autoplaying and looping decoration stops. A spinner that says “working” can stay.
- Feedback stays. Color, opacity, and state changes still happen. Reduced motion never means a control gives no response.
Android’s “Remove animations” goes further. It zeroes the animator duration scale, so Compose animations jump to their end state and no substitute fade runs. See android-compose.md.
Anti-Patterns
Linear motion on interactive elements. Nothing physical moves at constant velocity. Use a spring or an ease.
Identical timing for all properties. Opacity that finishes slightly before position reads as one arrival. Opacity that finishes long before it shows the destination before the journey is over.
Animating layout properties. width, height, top, left, margin, padding trigger layout every frame. Animate render-time properties: transform/translate/scale and opacity on web, scaleEffect/offset in SwiftUI, graphicsLayer in Compose.
Interrupting without velocity handoff. When a user reverses direction mid-flight, the new animation has to pick up the current velocity. Springs do. Duration-based animations restart from rest.
Staggering too much. 30-60ms between items creates rhythm. 150ms makes the UI feel slow. Stagger the first several items and bring the rest in together.
Decorative animation on high-frequency paths. Press feedback is snappy or micro, nothing longer.
Parallax on critical content. It can’t be what positions content a user needs to read.
Inline motion values. A raw cubic-bezier(), a bare millisecond count, or spring constants at a call site.
Not testing interruption. Tap, release, tap again rapidly. Swipe, reverse, swipe again. If it breaks, it’s duration-based where it should be a spring, or it restarts from a hard-coded start value.
Skipping reduced motion. Test every animated surface with the system setting on.
From motion-design: Android / Jetpack Compose Motion
The AnimationSpec system and Material 3 Expressive MotionScheme. On Android the scheme is the first source of motion values. The Motion.* tokens from cross-platform-tokens.md are for custom motion the scheme doesn’t cover. Theme setup, shape morphing, and typography are in the android-platform skill.
Status
MotionScheme and MaterialExpressiveTheme need the material3 version named in android-platform/SKILL.md (Version Snapshot). Library versions and opt-in requirements live there. The scheme values below are Material’s published ones as of November 2026.
Contents
Spring and Tween
// Spring is the default spec for Compose animations.
// Defaults: dampingRatio = 1f (no bounce), stiffness = Spring.StiffnessMedium (1500f).
spring<Float>(dampingRatio = 0.7f, stiffness = 247f)
// Named constants, for reading existing code
Spring.DampingRatioHighBouncy // 0.2
Spring.DampingRatioMediumBouncy // 0.5
Spring.DampingRatioLowBouncy // 0.75
Spring.DampingRatioNoBouncy // 1.0
Spring.StiffnessVeryLow // 50f
Spring.StiffnessLow // 200f
Spring.StiffnessMediumLow // 400f
Spring.StiffnessMedium // 1500f
Spring.StiffnessHigh // 10_000f
// Duration-based: for automated, non-interactive animation
tween<Float>(durationMillis = Motion.Duration.standard, easing = Motion.Ease.standard)
A spring retargets with its current velocity when interrupted. A tween restarts from rest. Compose’s LinearOutSlowInEasing, FastOutLinearInEasing, and FastOutSlowInEasing are the enter, exit, and standard curves. Use them through Motion.Ease.
M3 Expressive MotionScheme
Material’s motion physics system replaces the older easing and duration tokens with springs set once on the theme. In an M3 Expressive app, ignore the legacy tokens. A scheme supplies six specs: three speeds for each of two kinds of property.
- Spatial specs are for anything that moves on screen: position, size, rotation, corner radius. These overshoot.
- Effects specs are for color and opacity. No overshoot. A bouncing opacity reads as a flicker.
- Speed: default for most motion, fast for small elements, slow for large or full-screen ones.
Two preset schemes. Expressive is Material’s opinionated scheme and its recommendation for most products. It overshoots to add bounce. Standard has minimal bounce and is meant for utilitarian products.
| Spec |
Expressive (damping, stiffness) |
Standard (damping, stiffness) |
| fast spatial |
0.6, 800 |
0.9, 1400 |
| default spatial |
0.8, 380 |
0.9, 700 |
| slow spatial |
0.8, 200 |
0.9, 300 |
| fast / default / slow effects |
1.0 at 3800 / 1600 / 800 |
the same |
// Set once at the theme root
MaterialExpressiveTheme(
motionScheme = MotionScheme.expressive() // or MotionScheme.standard()
) {
App()
}
// Read at the call site
val motion = MaterialTheme.motionScheme
motion.defaultSpatialSpec<Dp>() // position, size
motion.fastSpatialSpec<Float>() // small elements
motion.slowSpatialSpec<IntOffset>() // large or full-screen moves
motion.defaultEffectsSpec<Color>() // color
motion.fastEffectsSpec<Float>() // alpha
Pick the scheme for the app at the theme. Calling MotionScheme.expressive() inline at a call site bypasses the theme and defeats the point. Material components already use the theme’s scheme. Custom components use it too, so they move like what surrounds them. Use a Motion.Spring.*() token only for motion the design specifies and the scheme doesn’t offer. LocalMotionScheme is gone from current material3. Read MaterialTheme.motionScheme.
Animation APIs
// animate*AsState: one value following a target
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "button_scale"
)
Box(Modifier.graphicsLayer { scaleX = scale; scaleY = scale })
// updateTransition: several properties driven by one state
val transition = updateTransition(targetState = isExpanded, label = "expansion")
val width by transition.animateDp(
transitionSpec = { MaterialTheme.motionScheme.defaultSpatialSpec() },
label = "width"
) { expanded -> if (expanded) 300.dp else 56.dp }
val alpha by transition.animateFloat(
transitionSpec = { MaterialTheme.motionScheme.defaultEffectsSpec() },
label = "alpha"
) { expanded -> if (expanded) 1f else 0f }
// Animatable: imperative control from a coroutine (gestures, sequencing)
val offset = remember { Animatable(0f) }
LaunchedEffect(target) {
offset.animateTo(targetValue = target, animationSpec = Motion.Spring.standard())
}
Calling animateTo on a running Animatable cancels the old animation and continues from the current value and velocity, which is the interruption behavior a gesture needs. During a drag call offset.snapTo(value). On release, animateTo with the gesture’s velocity as initialVelocity.
For appearing and disappearing content use AnimatedVisibility and AnimatedContent. In lazy lists, Modifier.animateItem() animates insertion, removal, and reordering.
Shared Element Transitions
SharedTransitionLayout {
AnimatedContent(targetState = showDetail, label = "detail") { detail ->
if (!detail) {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "hero"),
animatedVisibilityScope = this@AnimatedContent
)
.size(thumbnailSize)
.clickable { showDetail = true }
)
} else {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "hero"),
animatedVisibilityScope = this@AnimatedContent
)
.fillMaxWidth()
)
}
}
}
Elements are matched by key. sharedElement has to be called inside both a SharedTransitionLayout and an AnimatedContent or AnimatedVisibility scope. Pass those scopes down explicitly when the elements live in separate composables. Use sharedElement when the content is visually the same in both places (an image), sharedBounds when the container persists but its content changes (a card expanding into a screen).
Fling / Decay
For gesture release with natural deceleration. Decay has no target. It starts at the release velocity and slows to a stop.
val decay = rememberSplineBasedDecay<Float>()
val offset = remember { Animatable(0f) }
// In the drag-end callback, not in composition
scope.launch {
offset.animateDecay(initialVelocity = velocity, animationSpec = decay)
}
Performance
Read animated values as late as possible. A value read in composition recomposes every frame. The same value read inside a lambda modifier only re-runs layout or draw.
// Recomposes every frame
Box(Modifier.offset(x = offsetX.dp).alpha(alpha))
// Draw phase only
Box(Modifier.graphicsLayer {
translationX = offsetX
this.alpha = alpha
})
Prefer graphicsLayer for scale, translation, rotation, and alpha. Animating size or padding re-measures the subtree every frame. Keep that to small regions.
Font variation settings belong to the Font, not TextStyle, so animating a variable-font axis resolves a new FontFamily per value. Quantize the animated value (steps of 25 for weight) and remember the family per step. The full pattern is in android-platform.
Reduce Motion
Android’s accessibility setting is “Remove animations”, which sets the system animator duration scale to 0. Compose’s animation APIs read that scale: animate*AsState, Animatable, transitions, and AnimatedVisibility jump to their end state on the next frame. There’s nothing to substitute. A replacement fade would be skipped too.
What still needs handling:
- End states must be correct on their own. Don’t rely on an intermediate keyframe or an animation’s duration to sequence logic.
- Loops and self-driven motion. Infinite transitions, manual
withFrameNanos loops, shader time uniforms, and ambient effects don’t run. Check the setting and show a still state.
// False when the system animator duration scale is 0
val animationsEnabled = remember { ValueAnimator.areAnimatorsEnabled() }
if (animationsEnabled) AmbientBackground() else StaticBackground()
That value is read once. To react while the screen is open, register a ValueAnimator.DurationScaleChangeListener (API 33+).