Feedback¶
Feedback components communicate loading, transient status, and placeholder states.
Components¶
ProgressIndicatorSkeletonSkeletonBlockModifier.skeletonShimmerLoadingOverlayTypingIndicatorToastToastHostNoticeBannerChatLogChatComposerThumbsFeedbackRowAssistantContextCardAssistantQuickActionChipsAssistantStatusBannerAssistantLimitPromptCardAssistantDisclaimerFooter

Example¶
AppTheme {
ProgressIndicator(
progress = state.progress,
label = "Syncing",
)
}
Use progress = null for indeterminate loading. Toast is an in-composition visual component, not a platform android.widget.Toast wrapper.
Inline notice banner¶
NoticeBanner is a full-width, tonal banner that reports a status inside the screen layout:
a saved state, a degraded service, a validation failure. Unlike Toast, it is part of the
layout and stays until you remove it, so hoist its visibility and drop it from composition in
onDismiss or after onAction.
if (state.notice != null) {
NoticeBanner(
message = state.notice.message,
severity = NoticeSeverity.Error,
icon = AppTheme.icons.check,
iconContentDescription = "Error",
actionLabel = "Retry",
onAction = onRetry,
dismissContentDescription = "Dismiss",
onDismiss = onDismissNotice,
)
}
| Parameter | Notes |
|---|---|
message |
Caller-supplied banner text. |
severity |
Info (default), Success, Warning, or Error. Picks the tint from AppTheme.colors.primary, success, warning, or error. |
icon |
Optional leading IconToken. |
iconContentDescription |
Caller-supplied label for the icon, for example the severity name. null or blank makes the icon decorative. |
actionLabel / onAction |
The action button renders only when the label is non-blank and onAction is set. |
dismissContentDescription / onDismiss |
The dismiss button renders only when the label is non-blank and onDismiss is set, so it is never unlabeled. |
The container is the severity color at 12% over AppTheme.colors.surface, outlined in the full
severity color. Text and the icon use onSurface so every severity stays legible in light and dark
themes; the default light warning color is only about 2.4:1 against its own tint, below the 3:1
bar for graphics. Because color should not be the only signal, pass an icon with an
iconContentDescription when the severity matters to the reader.
Accessibility behavior:
- The icon and message are one TalkBack focus stop; the icon label is read before the message.
Erroris an assertive live region andWarninga polite one, so they are announced when they appear.InfoandSuccessare not announced on their own. The live region covers the message only, never the action or dismiss labels.- The action and dismiss controls are separate
Role.Buttonnodes with at least 48dp touch targets. The action sits below the message, end-aligned, and dismiss sits at the trailing edge, so the message keeps most of the width and wraps rather than clipping at large font scales. - Dismiss uses
AppTheme.icons.close, so provide icons toAppThemeas with the other components.
Chat logs¶
ChatLog renders reusable conversation bubbles with user/assistant alignment,
typing indicator state, and a retryable error bubble. Keep domain models in your
app and map them into ChatMessage values at the UI boundary:
ChatLog(
messages = state.turns.map { turn ->
ChatMessage(
id = turn.id,
sender = ChatMessageSender.Assistant,
state = ChatMessageState.Content(turn.text),
)
},
idleHint = "Ask a question to get started.",
typingDescription = "Assistant is responding",
errorAction = ChatErrorAction(
message = "Could not load the response.",
retryLabel = "Retry",
onRetry = onRetry,
),
)
Use ChatMessageState.Typing for in-flight responses and provide a localized
typingDescription so the state is announced accessibly. The typing bubble renders a
TypingIndicator. For markdown or rich
text, pass messageContent; the design-system component intentionally does not
depend on a markdown renderer:
ChatLog(
messages = messages,
idleHint = "",
errorAction = errorAction,
messageContent = { _, text ->
MarkdownText(markdown = text)
},
)
Typing indicator¶
TypingIndicator is three dots pulsing in sequence. It reads as "composing a reply" rather
than "loading". ChatLog uses it for ChatMessageState.Typing, and you can use it on its
own, for example inside a custom messageContent bubble while the streamed text is still
empty:
ChatLog(
messages = messages,
idleHint = "",
errorAction = errorAction,
messageContent = { _, text ->
if (text.isEmpty()) TypingIndicator() else MarkdownText(markdown = text)
},
)
Rendering the dots and the streamed text in the same bubble means the first token grows the bubble instead of replacing it.
| Parameter | Notes |
|---|---|
contentDescription |
Announced as a polite live region. Blank (default) hides the dots from accessibility, for when the enclosing bubble already announces the pending state. |
color |
Dot color. Defaults to AppTheme.colors.onSurfaceVariant. |
The wave is applied in the draw phase and never recomposes. It is static (all dots opaque)
under the system Remove animations setting and in @Preview.
Chat composer¶
ChatComposer is the input side of a chat: a growing message field (one line up to
maxLines) with a trailing 48dp button. The button sends a non-blank draft. When you pass
voiceInput and the draft is blank it becomes a microphone, then a stop button with a pulsing
level ring while listening, then a progress indicator while the final transcript is pending.
var draft by remember { mutableStateOf("") }
ChatComposer(
text = draft,
onTextChange = { draft = it },
onSend = {
send(draft)
draft = ""
},
placeholder = "Message",
sendContentDescription = "Send",
voiceInput = ChatComposerVoiceInput(
state = voiceState,
onStart = ::startRecording,
onStop = ::stopRecording,
startContentDescription = "Start voice input",
stopContentDescription = "Stop voice input",
processingContentDescription = "Processing voice input",
),
)
| Parameter | Notes |
|---|---|
text / onTextChange |
Hoisted draft. The composer never clears it; clear it in onSend. |
onSend |
Called for a non-blank draft from the button or the keyboard's Send action, only while enabled. |
placeholder |
Caller-supplied hint. Blank renders no hint. |
sendContentDescription |
Caller-supplied label for the send button. There is no English default. |
enabled |
Gates sending and starting voice input. Stopping a recording is always allowed. |
maxLines |
Lines the field grows to before it scrolls. |
voiceInput |
null for a text-only composer. Otherwise the voice state, callbacks, and the three caller-supplied labels. |
The composer does not record or transcribe. Start and stop recording in onStart and onStop,
and write the live transcript into text; the field is read-only while listening or
processing, so dictation can be reviewed and edited once it stops. Listening.level is
clamped to 0..1 and scales the ring from 0.8x to 1.25x; the ring stays at rest size under the
system Remove animations setting. Unavailable shows a disabled microphone.
Thumbs feedback row¶
ThumbsFeedbackRow is a pair of thumbs up / thumbs down buttons for rating a piece of content,
such as a generated answer. It is state-hoisted: it shows rating and reports taps through
onRatingChange. Submitting the rating, retrying a failed submission, and any follow-up dialog
stay with the caller.
var rating by remember { mutableStateOf(ThumbsRating.None) }
ThumbsFeedbackRow(
rating = rating,
onRatingChange = {
rating = it
submitRating(it)
},
positiveContentDescription = "Good response",
negativeContentDescription = "Bad response",
selectedStateDescription = "Selected",
)
| Parameter | Notes |
|---|---|
rating |
Hoisted ThumbsRating: None, Positive, or Negative. |
onRatingChange |
Called with the new rating. It is never called with the rating already shown. |
positiveContentDescription / negativeContentDescription |
Caller-supplied labels. There is no English default; a blank value leaves that button unlabeled. |
enabled |
Disabled buttons are dimmed, ignore taps, and keep showing rating. |
allowClearing |
true (default): tapping the selected choice reports ThumbsRating.None. false: tapping the selected choice does nothing. |
selectedStateDescription |
Optional caller-supplied stateDescription for the selected button. null or blank keeps the platform's default selected announcement. |
The thumbs glyphs are internal vectors, so the row does not need extra icons in AppTheme.
Accessibility behavior:
- The two buttons form a
selectableGroup(); each is aRole.Buttonthat exposesselected, andenabledwhen disabled. - Each button has at least a 48dp touch target.
- The selected button also gets a filled container, so selection is not conveyed by color alone.
Assistant chat surfaces¶
For quick-action assistant experiences, compose the chat log with the surrounding assistant components:
Column {
AssistantStatusBanner(message = unavailableMessage)
AssistantContextCard(
title = contextTitle,
highlight = contextHighlight,
body = AssistantContextBody(text = sourceText, isQuoted = true),
footnote = contextFootnote,
)
ChatLog(
messages = messages,
idleHint = idleHint,
errorAction = errorAction,
modifier = Modifier.weight(1f),
)
AssistantQuickActionChips(
actions = actions,
actionState = { action ->
AssistantQuickActionState(
isSelected = action in usedActions,
isEnabled = serviceAvailable && !isResponding && action !in usedActions,
)
},
label = { action -> action.label },
icon = { action -> action.icon },
onAction = onAction,
)
AssistantDisclaimerFooter(text = disclaimer)
}
AssistantQuickActionChips is generic over the action type, so apps can reuse it
for one-shot actions, re-runnable actions, selected/in-flight actions, or disabled
quota states. icon is optional and defaults to no leading icon per action.
AssistantLimitPromptCard covers quota, upgrade, and gated-action prompts with
caller-supplied copy and callbacks.
Skeleton loading placeholders¶
Skeleton renders a single full-width placeholder block sized to a minimum touch
target height — a quick ghost row for simple loading states. For finer control (an
explicit height, an optional fixed width, or a custom shape/color to mimic the real
content it stands in for), use SkeletonBlock directly:
AppTheme {
Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.x1)) {
SkeletonBlock(height = 20.dp, width = 160.dp)
SkeletonBlock(height = 16.dp)
}
}
Both are decorative loading placeholders and are hidden from the accessibility tree
(clearAndSetSemantics {}) since they carry no information a screen reader should stop
on.
Shimmer¶
Modifier.skeletonShimmer() sweeps a soft highlight band across the placeholders below
it so a skeleton reads as in-progress rather than frozen. Apply it once at the root of
a ghost layout, not per block:
Column(
modifier = Modifier.fillMaxWidth().skeletonShimmer(),
verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.md),
) {
repeat(3) {
Row(horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.md)) {
SkeletonBlock(height = 48.dp, width = 48.dp)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.sm)) {
SkeletonBlock(height = 16.dp)
SkeletonBlock(height = 16.dp, width = 120.dp)
}
}
}
}
The band's phase is a pure function of the animation frame clock, not per-node animation
state, so separate cards and lists that each use the modifier sweep in unison as one band
— regardless of when each entered composition. The highlight is
AppTheme.colors.shimmerHighlight (defaults to onSurface), painted at a low alpha and
masked with BlendMode.SrcAtop to the placeholder silhouette so it never tints the gaps.
The sweep is static — the modifier becomes an inert pass-through — when the system
reduce-motion / animator-duration-scale setting is off, when LocalSkeletonShimmerEnabled
is false (the escape hatch for deterministic screenshots), or under @Preview
inspection. It adds no pointer input and no semantics.
SkeletonBlock parameters¶
| Parameter | Type | Description |
|---|---|---|
height |
Dp |
Block height. |
modifier |
Modifier |
Applied to the block. |
width |
Dp? |
Fixed width; null expands to the constraints from modifier. |
shape |
Shape |
Clip shape. Defaults to AppTheme.shapes.small. |
background |
Color |
Placeholder color. Defaults to AppTheme.colors.surfaceContainer. |
Blocking loading overlay¶
LoadingOverlay wraps a screen or form and, while loading is true, covers it with a scrim
that swallows every touch, so controls without their own enabled flag can't be used either. Use
it for work the person started and must wait for, like signing in, submitting, or purchasing.
For loads people can keep interacting through, prefer LoadingState, ProgressIndicator,
or skeletons.
LoadingOverlay(
loading = state.isSubmitting,
contentDescription = "Submitting",
message = "Submitting your entry",
modifier = Modifier.fillMaxSize(),
) {
EntryForm(onSubmit = viewModel::submit)
}
| Parameter | Notes |
|---|---|
contentDescription |
Required, caller-localized. Announced as a polite live region while loading. |
message |
Optional text under the spinner. Visual only, so include it in contentDescription if screen-reader users need it. Blank renders nothing. |
showIndicator |
false shows the scrim only. Use it when the tapped control already shows its own inline spinner, so the screen doesn't stack two. |
While loading, the wrapped content is removed from the accessibility tree, so TalkBack
announces the operation instead of landing on controls that no longer respond. The scrim
fades with AppTheme.motion.shortMillis. The container sizes to its content, so pass
Modifier.fillMaxSize() to cover a whole screen.
Showing toasts¶
Toast is only the visual; ToastHost is the driver. Hold a ToastHostState,
place a ToastHost over your screen content, and call showToast from a
coroutine. It shows one toast at a time, animates it in and out, and suspends
until the toast is dismissed — returning how it ended (ToastResult).
Choose the lifetime per call with ToastDuration: Short/Long auto-dismiss
after a timeout, while Indefinite keeps the toast until the user or caller
dismisses it. An Indefinite toast with no action becomes tap-to-dismiss, so
pass dismissContentDescription for TalkBack.
Anchor the toast with position = ToastPosition.Top or ToastPosition.Bottom
(default). Pass an icon for a leading glyph; the icon is tinted to the surface
color by default — pass iconTint = Color.Unspecified to keep a colorful icon's
own colors.
val hostState = rememberToastHostState()
val scope = rememberCoroutineScope()
Box(Modifier.fillMaxSize()) {
// …your screen content…
PrimaryButton(
text = "Save",
onClick = {
scope.launch {
val result = hostState.showToast(
message = "Saved",
icon = AppTheme.icons.check,
actionLabel = "Undo",
duration = ToastDuration.Long,
)
if (result == ToastResult.ActionPerformed) {
// undo…
}
}
},
)
ToastHost(
hostState = hostState,
position = ToastPosition.Bottom, // or ToastPosition.Top
dismissContentDescription = "Dismiss",
)
}