Slider, RangeSlider, and Progress Indicators in Compose

Quick answer: Use Slider for one adjustable value, RangeSlider for a minimum and maximum, and a progress indicator to report work—not as a decorative loading animation. Keep values in screen state, show the current value in text, use steps only for real discrete choices, and choose determinate progress only when the app knows how much work is complete.

Sliders let users choose from a continuum; progress indicators communicate what the app is doing. They often share a track, but their semantics are opposite: a slider is input, while progress is status.

Pick the right component

NeedComponentExample
One value in a bounded rangeSliderVolume, brightness, text size
Minimum and maximum valuesRangeSliderPrice or date-range filter
Known fraction completedDeterminate LinearProgressIndicator or CircularProgressIndicatorFile upload, download, import
Work in progress with no reliable totalIndeterminate progress indicatorInitial content load

Android’s slider guide describes sliders as range selection controls, while the progress-indicator guide distinguishes determinate from indeterminate work. Do not show a 70% bar merely because the operation has been running for a while; it implies a fact your app does not know.

Keep slider state outside the control

The screen renders state and sends user intent to a state holder. A reusable slider should receive a value and a callback rather than owning an application preference itself.

data class PlayerSettingsUiState(
    val volume: Float = 0.7f,
)

@Composable
fun VolumeSetting(
    volume: Float,
    onVolumeChange: (Float) -> Unit,
) {
    Column {
        Text("Volume: ${(volume * 100).toInt()}%")
        Slider(
            value = volume,
            onValueChange = onVolumeChange,
            modifier = Modifier.fillMaxWidth(),
            valueRange = 0f..1f,
        )
    }
}

onValueChange can run repeatedly while the user drags. If an expensive effect belongs only at the end of a drag—such as refreshing a remote filter—use onValueChangeFinished to dispatch a separate event. Keep ordinary value formatting or business rules in the state layer when possible; the composable’s job is rendering and forwarding actions. This follows the state-hoisting pattern.

Use steps for genuinely discrete values

steps = 0 is continuous. Set steps when only a fixed set of values is meaningful, such as 0, 25, 50, 75, and 100. The slider documentation notes that the start and end values are boundaries, so steps counts the notches between them.

Slider(
    value = state.fontScale,
    onValueChange = { onAction(SettingsAction.FontScaleChanged(it)) },
    valueRange = 0.8f..1.4f,
    steps = 2,
)

Never use a continuous slider for an opaque enum such as “Compact / Comfortable / Spacious.” A segmented button or radio group is easier to understand and navigate. Use the selection-control guidance in Checkbox, RadioButton, and Switch when the choices are named categories rather than measurements.

Select a range with RangeSlider

RangeSlider has two thumbs and a ClosedFloatingPointRange<Float> value. It is appropriate for filters where both boundaries are visible and useful.

@Composable
fun PriceFilter(
    priceRange: ClosedFloatingPointRange<Float>,
    onPriceRangeChange: (ClosedFloatingPointRange<Float>) -> Unit,
    onChangeFinished: () -> Unit,
) {
    Column {
        Text(
            "Price: $${priceRange.start.toInt()} – $${priceRange.endInclusive.toInt()}",
        )
        RangeSlider(
            value = priceRange,
            onValueChange = onPriceRangeChange,
            modifier = Modifier.fillMaxWidth(),
            valueRange = 0f..500f,
            onValueChangeFinished = onChangeFinished,
        )
    }
}

The RangeSlider API reference confirms the two-value range and optional completion callback. Always show the selected boundaries in a readable form; the track alone is not enough for precision, screen readers, or large ranges.

For filters that require an exact amount, pair the slider with editable fields or a dialog. A slider is excellent for exploration, not necessarily exact entry.

Show determinate and indeterminate progress honestly

Use the current Material 3 lambda overload for determinate progress:

@Composable
fun UploadStatus(progress: Float?) {
    if (progress == null) {
        LinearProgressIndicator(Modifier.fillMaxWidth())
        Text("Preparing upload…")
    } else {
        LinearProgressIndicator(
            progress = { progress },
            modifier = Modifier.fillMaxWidth(),
        )
        Text("${(progress * 100).toInt()}% uploaded")
    }
}

The CircularProgressIndicator reference documents the lambda progress API; the older Float overload is deprecated. It also clamps values outside 0f..1f, but application state should still provide a valid fraction.

Use a linear indicator when it can sit naturally with content or a task list. A circular indicator fits compact local waiting states, such as a button-sized or centered loading area. Pair either with a concise status message when users need to understand the wait.

Animate only when it improves comprehension

Material 3 does not animate between determinate values automatically. If raw updates are jumpy, animate the value with the recommended ProgressIndicatorDefaults.ProgressAnimationSpec, then pass the animated value through the lambda API.

val displayedProgress by animateFloatAsState(
    targetValue = state.uploadProgress,
    animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
    label = "upload progress",
)

LinearProgressIndicator(
    progress = { displayedProgress },
    modifier = Modifier.fillMaxWidth(),
)

Do not invent progress through animation. Animate a real reported value only, and keep the state update source outside the UI. The Compose UI phases guide is useful background when an animation or frequent state update needs troubleshooting.

Accessibility and testing checklist

  • Put a visible numeric value near a slider when precision matters.
  • State the unit: percent, minutes, currency, or distance—not only a raw number.
  • Do not convey completion using color alone; add text such as “Upload complete.”
  • Keep disabled controls explained and avoid updating a progress indicator after its task is cancelled.
  • Test drag, keyboard focus, TalkBack announcements, small screens, and large fonts.

Compose semantics expose range information for sliders and determinate progress. In a UI test, assert the visible value or a stable test tag after dispatching an event, rather than asserting thumb pixels.

Common mistakes

Starting network work from onValueChange

Dragging emits many values. Update the UI state continuously, but defer expensive work to onValueChangeFinished, debounce in the state layer, or apply through an explicit button.

Calling an unknown wait “75% complete”

Use indeterminate progress and a helpful message until the operation can report a real fraction.

Hiding exact values

Range handles are not precise enough for pricing, accessibility, or a user comparing values. Render the selected amount in text.

FAQ

When should I use steps?

When values between ticks are invalid or meaningless. Keep it continuous for true measurements such as volume or opacity.

Is a progress indicator a loading state by itself?

No. The screen still needs an explicit loading, success, error, or empty state. The indicator only visualizes the current part of that state.

Should a RangeSlider make API calls while dragging?

Usually not. Update the local filter state during drag and run the expensive update after the gesture finishes or after a deliberate apply action.

Summary

Use a slider for one range value, a range slider for two boundaries, and progress indicators only for real task status. Lift values into screen state, expose readable units, avoid expensive work for every drag event, and select determinate progress only when you know the fraction completed.