The Jetpack Compose components Material 3 doesn't ship, built to feel like they always belonged there.
Full documentation, with examples and every parameter for each component:
Read the docs
Every component takes its colors, shapes and typography from MaterialTheme, so it matches whatever theme your app already uses, dynamic color included. Each one also has colors() / accentColors() helpers, a slot for drawing parts yourself, animations you can tune or turn off, haptics, right-to-left support and screen-reader semantics.
![]() Date picker |
![]() Wheel picker |
![]() OTP input |
![]() Rating bar |
![]() Timeline |
![]() Color picker |
The library is published to Maven Central. Make sure mavenCentral() is in your repositories, then add:
// build.gradle.kts
dependencies {
implementation("io.github.nameisjayant:compose-components:0.1.0")
}Version catalog
[versions]
composeComponents = "0.1.0"
[libraries]
compose-components = { group = "io.github.nameisjayant", name = "compose-components", version.ref = "composeComponents" }implementation(libs.compose.components)| Requirement | |
|---|---|
minSdk |
24. Dates use the library's own CalendarDate, so you don't need core library desugaring |
| Compose | Material 3 is brought in through the Compose BOM as an api dependency |
| Imports | Everything lives under com.nameisjayant.components.<component>, e.g. com.nameisjayant.components.datepicker.* |
Once you've learned one component, you know how the rest are put together:
| Pattern | What it looks like |
|---|---|
| Theme first | XxxDefaults.colors(...) reads MaterialTheme.colorScheme; override only the colors you care about |
| One accent | XxxDefaults.accentColors(accent) repaints the highlights in a single brand color |
| Tweak a copy | Every colors class has copy(...) |
| State or value | The date picker is state-driven; the wheel and color pickers come as both a rememberXxxState() overload and a plain value + callback overload; the OTP input and rating bar are plain value + callback |
| Custom drawing | A slot such as dayContent, cellContent, itemContent or indicator gets the item's full state |
| Motion | Animated components take an XxxAnimation(...) to tune them, or XxxAnimation.None to turn them off |
| Survives rotation | rememberXxxState() uses rememberSaveable, so state survives configuration changes and process death |
A month calendar for picking one date, a date range or several dates. Swipe or use the arrows to change month, and tap the month title to jump to another year.
![]() Range, across months |
![]() Multiple, weekdays only, event dots |
![]() Single, price labels, year grid |
val state = rememberComposeDatePickerState() // Single by default
ComposeDatePicker(state = state)
state.selectedDate // CalendarDate? e.g. 2026-10-22// One date
val single = rememberComposeDatePickerState(
selectionMode = DateSelectionMode.Single,
initialSelectedDate = CalendarDate.today(),
)
single.selectedDate // CalendarDate?
// A start and an end. The first tap sets the start, the second the end.
val range = rememberComposeDatePickerState(selectionMode = DateSelectionMode.Range)
range.selectedRange // ClosedRange<CalendarDate>?, null until both ends are picked
range.rangeStart; range.rangeEnd // each end on its own
// Any number of dates. Tapping a selected date removes it.
val multiple = rememberComposeDatePickerState(
selectionMode = DateSelectionMode.Multiple,
maxSelectionCount = 5, // optional cap
)
multiple.selectedDates // Set<CalendarDate>The state can also be driven from code: select(date), setRange(start, end), clearSelection(), isSelected(date), isSelectable(date), hasSelection, and displayedMonth (read it, or set it to jump to a month).
val today = CalendarDate.today()
val state = rememberComposeDatePickerState(
selectionMode = DateSelectionMode.Range,
minDate = today, // hard bounds: the picker won't page past them
maxDate = today.plusMonths(6),
selectableDates = SelectableDates.WeekdaysOnly and
SelectableDates.except(publicHolidays), // combine rules with `and` / `or`
allowDisabledDatesInRange = false, // a range can't jump over disabled days
)
ComposeDatePicker(state, strikeThroughDisabledDays = true)| Built-in rule | Selects |
|---|---|
SelectableDates.All |
every date (the default) |
SelectableDates.WeekdaysOnly / WeekendsOnly |
Monday to Friday / Saturday and Sunday |
SelectableDates.notBefore(date) / notAfter(date) |
on or after / on or before a date |
SelectableDates.except(dates) |
everything except a collection of dates |
SelectableDates.exceptDaysOfWeek(WeekDay.Sunday, ...) |
everything except some days of the week |
SelectableDates { date -> ... } |
your own rule (it's a fun interface) |
state.selectableDates is mutable, so you can swap the rule at runtime, for example once booked dates have loaded.
Return a DayDecoration for the days that need one: up to three dots, a short label, a tinted background, or a different number color. Mix them however you like.
ComposeDatePicker(
state = state,
decoration = { date ->
when {
date in holidays -> DayDecoration(containerColor = Color(0xFFFBE4DC), contentColor = Color(0xFFB4472B))
date in events -> DayDecoration(dots = listOf(Color.Unspecified, Color.Red)) // Unspecified = colors.decorationColor
else -> prices[date]?.let { DayDecoration(label = "$$it", labelColor = Color(0xFF0F8A66)) }
}
},
)
DayDecoration.dot() // shorthand for a single dotdayContent replaces what's drawn inside each day. The picker still draws the selection background, today's outline and the range band, and LocalContentColor is set to the color the default text would use.
ComposeDatePicker(
state = state,
dayContent = { info: DayInfo ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("${info.date.dayOfMonth}", fontWeight = if (info.isToday) FontWeight.Bold else null)
info.decoration?.label?.let { Text(it, fontSize = 8.sp) }
}
},
)DayInfo has date, isCurrentMonth, isEnabled, isSelected, isToday, isRangeStart, isRangeEnd, isInRange and decoration.
if (showPicker) {
val state = rememberComposeDatePickerState(initialSelectedDate = booking.date)
ComposeDatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = { booking = booking.copy(date = state.selectedDate); showPicker = false }) { Text("OK") }
},
dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Cancel") } },
) {
ComposeDatePicker(state)
}
}ComposeDatePicker(
state = state,
colors = ComposeDatePickerDefaults.accentColors(Color(0xFF0F8A66)), // or .colors(...) / .copy(...)
shape = RoundedCornerShape(28.dp),
dayShape = ComposeDatePickerDefaults.RoundedDayShape, // CircleShape by default
firstDayOfWeek = WeekDay.Monday, // the locale's first day by default
showAdjacentMonthDays = true,
highlightWeekends = true,
title = null, // hide the small labelβ¦
headline = { Text("When are you flying?") }, // β¦or replace the big summary
)The picker uses its own small CalendarDate / CalendarMonth types instead of java.time, so it works on minSdk 24 without desugaring.
val date = CalendarDate(2026, 10, 22) // or CalendarDate.today(), CalendarDate.parse("2026-10-22")
date.plusDays(7); date.minusMonths(1); date.daysUntil(other)
date.dayOfWeek; date.isWeekend; date.calendarMonth
date.format("yMMMd") // "Oct 22, 2026", ordered for the user's locale
date.toEpochMillisUtc() // β CalendarDate.ofEpochMillisUtc(millis)
date.epochDay // β CalendarDate.ofEpochDay(days)
CalendarDate.from(calendar) // from java.util.Calendar
val month = CalendarMonth(2026, 10) // or CalendarMonth.current()
month.firstDay; month.lastDay; month.lengthOfMonth; date in month
month.format("MMMMy") // "October 2026"All parameters
ComposeDatePicker
| Parameter | Default | Description |
|---|---|---|
state |
Selection, displayed month and rules, from rememberComposeDatePickerState |
|
colors |
ComposeDatePickerDefaults.colors() |
Colors of every part of the picker |
shape |
RectangleShape |
Outline of the picker; also clips its background |
dayShape |
CircleShape |
Shape of the selection, today's outline and decoration backgrounds |
firstDayOfWeek |
locale's | Leftmost column |
showAdjacentMonthDays |
false |
Show neighbouring months' days in empty cells (not tappable) |
highlightToday |
true |
Outline today's date |
highlightWeekends |
false |
Color Saturday and Sunday with weekendContentColor |
strikeThroughDisabledDays |
false |
Strike through disabled days as well as dimming them |
yearSelectionEnabled |
true |
Tapping the month title opens a year grid |
decoration |
null |
(CalendarDate) -> DayDecoration? |
title |
"Select date" | Small label at the top; null hides it |
headline |
selection summary | Large text under the title; null hides it (hiding both also hides the divider) |
dayContent |
null |
@Composable (DayInfo) -> Unit to draw each day yourself |
rememberComposeDatePickerState: selectionMode, initialSelectedDate, initialSelectedRange, initialSelectedDates, initialDisplayedMonth, minDate (1900-01-01), maxDate (2100-12-31), selectableDates, maxSelectionCount, allowDisabledDatesInRange.
ComposeDatePickerDialog: onDismissRequest, confirmButton, dismissButton, shape, containerColor (match it to the picker's containerColor), tonalElevation, properties, content.
ComposeDatePickerColors: containerColor, titleContentColor, headlineContentColor, navigationContentColor, weekdayContentColor, weekendContentColor, dayContentColor, disabledDayContentColor, adjacentMonthDayContentColor, todayContentColor, todayBorderColor, selectedDayContainerColor, selectedDayContentColor, rangeContainerColor, rangeContentColor, decorationColor, dividerColor, yearContentColor, currentYearContentColor, selectedYearContainerColor, selectedYearContentColor.
accentColors(accent, onAccent, containerColor) derives the selection, today, range and year colors from one accent.
A scrolling wheel for picking one item from a list: hours, minutes, months, sizes, anything. Drag or fling it and it snaps so a row rests on the selection line. Tap a row to spin to it.
var month by rememberSaveable { mutableIntStateOf(0) }
ComposeWheelPicker(
items = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"),
selectedIndex = month,
onSelectedIndexChange = { month = it }, // called once the wheel comes to rest
)Change month from outside and the wheel spins to it.
val minutes = rememberComposeWheelPickerState(initialIndex = 30)
val scope = rememberCoroutineScope()
ComposeWheelPicker(
items = (0..59).map { "%02d".format(it) },
state = minutes,
cyclic = true, // loops forever in both directions
)
minutes.selectedIndex // follows the wheel live, even mid-fling
minutes.isScrollInProgress
scope.launch { minutes.animateScrollToIndex(0) } // or scrollToIndex(0) to jump; cyclic takes the short way roundTurn each wheel's own indicator off and draw one band behind the row, one itemHeight tall:
Box(contentAlignment = Alignment.Center) {
ComposeWheelPickerDefaults.SelectionBand(Modifier.fillMaxWidth().height(ComposeWheelPickerDefaults.ItemHeight))
Row(verticalAlignment = Alignment.CenterVertically) {
ComposeWheelPicker(items = (1..12).toList(), state = hours, cyclic = true, selectionIndicator = null,
modifier = Modifier.width(72.dp), contentDescription = "Hours")
Text(":", style = MaterialTheme.typography.titleLarge)
ComposeWheelPicker(items = (0..59).map { "%02d".format(it) }, state = minutes, cyclic = true,
selectionIndicator = null, modifier = Modifier.width(72.dp), contentDescription = "Minutes")
ComposeWheelPicker(items = listOf("AM", "PM"), state = amPm, selectionIndicator = null,
modifier = Modifier.width(72.dp))
}
}val days = (1..CalendarMonth(year, month).lengthOfMonth).toList() // the list can change, e.g. 31 β 28
ComposeWheelPicker(
items = days,
state = dayState, // keeps its item, or the last one if the list got shorter
style = WheelPickerStyle.Flat, // rows shrink and fade instead of curving
selectionIndicator = { ComposeWheelPickerDefaults.SelectionLines() },
isItemEnabled = { index -> days[index] >= earliestDay }, // dimmed and skipped
)WheelPickerStyle(curved, outerAlpha, outerScale) lets you tune the look between Wheel and Flat.
ComposeWheelPicker(items = sizes, state = state) { item: WheelPickerItem<Size> ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(item.value.label, fontWeight = if (item.isSelected) FontWeight.Bold else null)
if (!item.isEnabled) Text(" sold out", style = MaterialTheme.typography.labelSmall)
}
}Custom rows still curve and fade with the wheel, and get the selected or plain text color as LocalContentColor.
All parameters
| Parameter | Default | Description |
|---|---|---|
items |
Any List<T>; may change while shown |
|
state or selectedIndex + onSelectedIndexChange |
Pick the overload you need. The state overload also takes an optional onSelectedIndexChange |
|
modifier |
Set the width here; without one it's ComposeWheelPickerDefaults.Width (96dp). Height is itemHeight Γ visibleItemCount |
|
enabled |
true |
Ignore touch and use disabled colors when false |
cyclic |
false |
Wrap the list so it spins endlessly |
visibleItemCount |
5 |
Rows shown at once; must be odd |
itemHeight |
48.dp |
Height of each row |
style |
WheelPickerStyle.Wheel |
Wheel (curved drum), Flat, or WheelPickerStyle(curved, outerAlpha, outerScale) |
textStyle / selectedTextStyle |
titleLarge, centered |
Label styles; their color comes from colors |
colors |
ComposeWheelPickerDefaults.colors() |
textColor, selectedTextColor, disabledItemTextColor, disabledTextColor, indicatorColor, indicatorLineColor |
selectionIndicator |
SelectionBand() |
Drawn behind the selected row; SelectionLines(), your own, or null |
fadeEdges |
true |
Fade rows out at the top and bottom |
isItemEnabled |
all enabled | Disabled items are dimmed and skipped |
selectOnTap |
true |
Tapping a row spins to it |
hapticFeedbackEnabled |
true |
A light tick each time a row passes the line |
flingBehavior |
snap fling | State overload only; how a fling decays and snaps |
contentDescription |
null |
What the wheel picks ("Hours"), read out with the selected label |
itemLabel |
toString() |
Text of each row, also used for accessibility |
itemContent |
null |
@Composable (WheelPickerItem<T>) -> Unit with index, value, isSelected, isEnabled |
Accessibility: the wheel is one adjustable control. Screen readers read the description and the selected label, and swipe up or down to change it.
A one-time-code field split into cells. Typing fills the cells one by one, backspace clears them, and pasting or SMS autofill drops a whole code in at once. Characters the input doesn't accept, such as the dash in 529-174, are skipped.
var code by rememberSaveable { mutableStateOf("") }
ComposeOtpInput(
value = code,
onValueChange = { code = it },
onComplete = { viewModel.verify(it) }, // called when the last cell is filled; the keyboard closes
)ComposeOtpInput(
value = code,
onValueChange = { code = it; wrongCode = false },
isError = wrongCode, // red cells, the row shakes and the phone buzzes when it turns true
)ComposeOtpInput(
value = code,
onValueChange = { code = it },
length = 6,
cellStyle = OtpCellStyle.Filled, // Outlined | Filled | Underlined
colors = ComposeOtpInputDefaults.accentColors(Color(0xFF0F8A66), OtpCellStyle.Filled),
cellSize = DpSize(Dp.Unspecified, 52.dp), // unspecified width = share the row
groupSize = 3, // 529 β 174
separator = { ComposeOtpInputDefaults.Separator() },
)
// Round cells
ComposeOtpInput(code, { code = it }, cellShape = ComposeOtpInputDefaults.CircleCellShape, cellSize = DpSize(46.dp, 46.dp))Pass the same style to colors(style) / accentColors(accent, style) as to cellStyle; they pick suitable container and border colors for it.
// Masked PIN that shows each digit for a moment
ComposeOtpInput(value = pin, onValueChange = { pin = it }, length = 4, mask = true, revealMillis = 600)
// Letters, uppercased as they're typed or pasted
ComposeOtpInput(value = code, onValueChange = { code = it }, inputType = OtpInputType.Letters, uppercase = true)
// Your own rule, e.g. hex characters
ComposeOtpInput(value = code, onValueChange = { code = it }, inputType = OtpInputType.Any,
charFilter = { it.isDigit() || it.lowercaseChar() in 'a'..'f' })OtpInputType is Digits (number pad), Letters, Alphanumeric or Any.
// PIN dots instead of boxes
ComposeOtpInput(value = pin, onValueChange = { pin = it }, length = 4) { cell: OtpCell ->
Box(
Modifier
.size(16.dp)
.background(if (cell.isFilled) MaterialTheme.colorScheme.primary else Color.Transparent, CircleShape)
.border(2.dp, if (cell.isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, CircleShape),
)
}OtpCell has index, char, text (masked if needed), isActive, isFilled, isError and isEnabled.
All parameters
| Parameter | Default | Description |
|---|---|---|
value / onValueChange |
The code, and the callback with the new code | |
length |
6 |
Number of cells |
onComplete |
null |
Called once with the full code when the last cell is filled |
enabled / readOnly |
true / false |
Disabled ignores focus; read-only can be focused and copied but not changed |
isError |
false |
Error colors, plus a shake and a buzz when it turns true |
inputType |
Digits |
Accepted characters and default keyboard |
charFilter |
inputType::accepts |
Overrides which characters are accepted |
uppercase |
false |
Capitalise letters |
mask / maskChar / revealMillis |
false / β / 600 |
Password-style masking, briefly showing the last character |
placeholder |
null |
Character shown in empty cells, e.g. '0' |
cellStyle |
Outlined |
Outlined, Filled or Underlined |
cellSize |
48 Γ 56 dp |
Dp.Unspecified width shares the row; unspecified height makes cells square |
cellSpacing |
8.dp |
Gap between cells and around separators |
cellShape |
RoundedCornerShape(12.dp) |
CircleCellShape for round cells |
borderWidth / focusedBorderWidth |
1.dp / 2.dp |
Idle and active/error borders (or underlines) |
textStyle |
headlineSmall, medium |
Its color comes from colors |
colors |
colors(cellStyle) |
Container, border and text colors for idle, focused, filled, error and disabled cells, plus placeholderColor and cursorColor |
groupSize / separator |
0 / dash |
Split into groups with a separator between them |
showCursor |
true |
Blinking cursor in the active cell |
autoFocus |
false |
Focus and open the keyboard on first show |
clearFocusOnComplete |
true |
Close the keyboard when complete |
keyboardType / imeAction / keyboardActions |
from inputType / Done / default |
Keyboard options |
animation |
OtpInputAnimation.Default |
OtpInputAnimation(slideInText, popScale, blinkCursor, shakeOnError, shakeDistance) or .None |
hapticFeedbackEnabled |
true |
Confirm tick when complete, buzz on error |
interactionSource |
null |
Observe focus and press interactions |
cellContent |
null |
@Composable (OtpCell) -> Unit to draw cells yourself |
Cells always run left to right, also in right-to-left layouts, since codes are read that way. The field tells autofill services it holds an SMS one-time code.
A row of stars, hearts, circles, or any Shape or Painter, for showing and picking a rating. Tap an item to rate, or drag across the bar and the rating follows your finger.
var rating by rememberSaveable { mutableFloatStateOf(3f) }
ComposeRatingBar(
rating = rating,
onRatingChange = { rating = it },
onRatingChangeFinished = { viewModel.save(rating) }, // when the finger lifts
)ComposeRatingBar(rating, { rating = it }, stepSize = 0.5f) // halves (1f = whole, 0f = any value)
ComposeRatingBar(rating = 3.7f, onRatingChange = null) // read-only; fractions draw partly filled
ComposeRatingBar(
rating = rating,
onRatingChange = { rating = it },
maxRating = 10,
minRating = 1f, // at least one star
allowClear = false, // tapping the current rating won't reset it
)// Gold stars
ComposeRatingBar(rating, { rating = it }, colors = ComposeRatingBarDefaults.accentColors(ComposeRatingBarDefaults.Gold))
// Hearts
ComposeRatingBar(rating, { rating = it }, shape = ComposeRatingBarDefaults.HeartShape,
colors = ComposeRatingBarDefaults.accentColors(Color(0xFFE5484D)))
// Outline-only empty stars
ComposeRatingBar(rating, { rating = it }, shape = ComposeRatingBarDefaults.SharpStarShape,
borderWidth = ComposeRatingBarDefaults.OutlineWidth, colors = ComposeRatingBarDefaults.outlinedColors())
// Any star, or any Compose shape
ComposeRatingBar(rating, { rating = it }, shape = ComposeRatingBarDefaults.starShape(points = 6, innerRadiusRatio = 0.5f, roundness = 0.3f))
ComposeRatingBar(rating, { rating = it }, shape = CircleShape, itemSize = 20.dp, itemSpacing = 4.dp)
// An icon, tinted with the bar's colors
ComposeRatingBar(rating, { rating = it }, painter = painterResource(R.drawable.ic_thumb_up))When the rating grows, items fill one after another with a soft bounce, the tapped item pops and sends out a halo, and the item under your finger lifts while you drag.
ComposeRatingBar(
rating, { rating = it },
animation = RatingBarAnimation(staggerMillis = 80, popScale = 1.4f, showHalo = false), // or RatingBarAnimation.None
)All parameters
| Parameter | Default | Description |
|---|---|---|
rating |
Current rating, 0..maxRating |
|
onRatingChange |
New rating while tapping or dragging; null makes the bar read-only |
|
maxRating |
5 |
Number of items |
stepSize |
1f |
1f whole, 0.5f halves, 0f any value |
minRating |
0f |
Lowest rating the user can pick |
enabled |
true |
Ignore input and use the disabled colors when false |
allowClear |
true |
Tapping the current rating resets it to minRating |
itemSize / itemSpacing |
32.dp / 6.dp |
Item size and gap |
shape |
StarShape |
StarShape, SharpStarShape, HeartShape, starShape(points, innerRadiusRatio, roundness) or any Shape |
painter |
null |
Draw a tinted Painter instead of shape |
borderWidth |
0.dp |
Stroke around each item; use OutlineWidth with outlinedColors() |
colors |
ComposeRatingBarDefaults.colors() |
activeColor, inactiveColor, activeBorderColor, inactiveBorderColor, disabledActiveColor, disabledInactiveColor, haloColor |
animation |
RatingBarAnimation.Default |
RatingBarAnimation(staggerMillis, waveScale, popScale, dragScale, showHalo) or .None |
hapticFeedbackEnabled |
true |
Tick as the rating changes under the finger |
onRatingChangeFinished |
null |
Called when a tap or drag ends |
TalkBack users can adjust the rating with the standard slider actions. Right-to-left layouts fill from the right. Animations run in the draw phase, so the bar doesn't recompose while it moves.
Steps joined by a line: order tracking, onboarding progress, a history of events. Each step gets an indicator on the line, content beside it and, optionally, opposite content on the other side. The line is drawn as reached up to the furthest reached step, and grows into the next step when it's reached.
![]() Vertical, with opposite content |
![]() Horizontal: numbers, error state, icons |
ComposeTimeline(
items = orderSteps,
stepState = { index, _ -> TimelineStepState.forProgress(index, currentStep) },
pendingLineStyle = TimelineLineStyle.Dashed, // the line after the current step
oppositeContent = { step -> Text(step.item.time) },
) { step ->
Text(step.item.title, style = MaterialTheme.typography.titleSmall)
Text(step.item.description, style = MaterialTheme.typography.bodySmall)
}TimelineStepState is Completed, Active (pulses), Upcoming or Error. forProgress(index, currentIndex) makes earlier steps completed, the current one active and later ones upcoming; pass currentIndex = items.size to complete them all. Bump currentStep and the line fills into the next step.
ComposeTimeline(
items = listOf("Cart", "Address", "Payment", "Done"),
orientation = TimelineOrientation.Horizontal,
stepState = { index, _ -> if (index == current && paymentFailed) TimelineStepState.Error else TimelineStepState.forProgress(index, current) },
indicatorSize = 28.dp,
indicator = { step ->
if (step.state == TimelineStepState.Completed) StatusIndicator(step.state)
else NumberIndicator("${step.index + 1}", step.state)
},
) { step -> Text(step.item) }Horizontal steps share the width equally; give them an itemWidth to put a long timeline in a horizontally scrolling container. Tracks line up however tall the content above them is.
indicator = { step -> StatusIndicator(step.state) } // default: ring, pulsing dot, check, "!"
indicator = { step -> NumberIndicator("${step.index + 1}", step.state) }
indicator = { step -> IconIndicator(painterResource(R.drawable.ic_truck), step.state) } // Painter or ImageVector
indicator = { step -> MyBadge(step, colors.completedColor, indicatorSize) } // the scope has colors, indicatorSize, animation
lineStyle = TimelineLineStyle.Solid // reached part
pendingLineStyle = TimelineLineStyle.Dotted // the rest
TimelineLineStyle(width = 3.dp, dashLength = 8.dp, gapLength = 4.dp, cap = StrokeCap.Round)ComposeTimeline(
items = events,
contentSide = TimelineContentSide.Alternate, // Start | End | Alternate (line down the middle)
indicatorAlignment = TimelineIndicatorAlignment.Center, // level with the start of the content, or centered
itemSpacing = 24.dp, contentSpacing = 16.dp, lineGap = 4.dp,
oppositeContentFraction = 0.3f,
key = { it.id }, // keeps animations when items move
) { step -> EventCard(step.item, alignEnd = step.contentSide == TimelineContentSide.Start) }LazyColumn(contentPadding = PaddingValues(16.dp)) {
item { Header() }
timelineItems(events, key = { it.id }) { step -> EventCard(step.item) }
item { LoadMore() }
}timelineItems takes the same parameters as the vertical ComposeTimeline, plus contentType.
All parameters
| Parameter | Default | Description |
|---|---|---|
items |
One per step | |
stepState |
all Completed |
(index, item) -> TimelineStepState |
key |
null |
Stable key per item |
orientation |
Vertical |
Vertical or Horizontal |
contentSide |
End |
Start, End or Alternate (start/end edges vertically, above/below horizontally) |
indicatorAlignment |
Start |
Start or Center relative to the content |
indicatorSize |
24.dp |
Default indicator size, and the width of the line's track |
itemSpacing / contentSpacing / lineGap |
24.dp / 16.dp / 0.dp |
Gaps between steps, beside the line, and around indicators |
itemWidth |
Dp.Unspecified |
Horizontal only; unspecified shares the width |
oppositeContentFraction |
0.3f |
Share of the space that opposite content gets |
lineStyle / pendingLineStyle |
Solid / lineStyle |
Solid, Dashed, Dotted or TimelineLineStyle(width, dashLength, gapLength, cap) |
colors |
ComposeTimelineDefaults.colors() |
completedColor, onCompletedColor, activeColor, onActiveColor, upcomingColor, errorColor, onErrorColor, containerColor, completedLineColor, upcomingLineColor, haloColor |
animation |
TimelineAnimation.Default |
TimelineAnimation(revealItems, staggerMillis, lineFillMillis, pulseActive) or .None |
indicator |
StatusIndicator |
TimelineIndicatorScope.(TimelineStep<T>) -> Unit |
oppositeContent |
null |
Drawn on the other side of the line |
content |
Drawn beside each indicator |
TimelineStep<T> has index, item, state, count, contentSide, orientation, isFirst and isLast. Each step reads to screen readers as one item with its state ("Completed", "In progress"β¦) and its place in the list. Right-to-left layouts mirror everything.
A full color picker: a hue/saturation/brightness area in one of three styles, then an alpha slider, a before/after preview, a hex field and preset swatches. It works in HSV, so dragging to gray or black and back keeps the hue.
![]() ColorPickerStyle.Wheel |
![]() ColorPickerStyle.Ring |
![]() ColorPickerStyle.Square |
// Plain value + callback
var color by remember { mutableStateOf(Color(0xFF3D5AFE)) }
ComposeColorPicker(color = color, onColorChange = { color = it })
// Or a state you can read and set any time
val state = rememberComposeColorPickerState(initialColor = Color(0xFF3D5AFE))
ComposeColorPicker(
state = state,
style = ColorPickerStyle.Ring, // Wheel | Ring | Square
originalColor = Color(0xFF3D5AFE), // split preview; tap the old half to go back
onColorChangeFinished = { recents.add(it) }, // finger lifted, swatch tapped or hex entered
)
state.color = Color.Red // or state.hsvColor = HsvColor(β¦)ComposeColorPicker(
state = state,
showAlpha = false, // no alpha slider; the hex field shows RRGGBB
showPreview = true,
showHexField = true,
swatches = brandColors, // emptyList() for none
colors = ComposeColorPickerDefaults.accentColors(MaterialTheme.colorScheme.tertiary),
thumbSize = 24.dp, sliderHeight = 20.dp, ringWidth = 24.dp,
panelShape = RoundedCornerShape(8.dp), swatchSize = 32.dp, spacing = 12.dp,
)The hex field accepts RGB, ARGB, RRGGBB and AARRGGBB, with or without #. Codes without alpha keep the current alpha.
if (show) {
val state = rememberComposeColorPickerState(initialColor = current)
ComposeColorPickerDialog(
onDismissRequest = { show = false },
title = { Text("Pick a color") },
confirmButton = { TextButton(onClick = { save(state.color); show = false }) { Text("OK") } },
dismissButton = { TextButton(onClick = { show = false }) { Text("Cancel") } },
) {
ComposeColorPicker(state, Modifier.padding(horizontal = 24.dp), originalColor = current)
}
}The dialog's content scrolls when the screen is too short for it.
Every piece is public and stateless (hsvColor + onHsvColorChange), so you can lay them out however you like:
var hsv by remember { mutableStateOf(HsvColor.from(Color(0xFF3D5AFE))) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SaturationValuePanel(hsv, { hsv = it }, aspectRatio = 2f)
HueSlider(hsv, { hsv = it })
SaturationSlider(hsv, { hsv = it })
BrightnessSlider(hsv, { hsv = it })
AlphaSlider(hsv, { hsv = it })
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ColorPreview(hsv.toColor())
ColorHexField(hsv, { hsv = it }, Modifier.weight(1f))
}
ColorSwatches(ComposeColorPickerDefaults.Swatches, selectedColor = hsv.toColor(), onSwatchClick = { hsv = HsvColor.from(it, hsv) })
}Also available: HueSaturationWheel and HueRingPanel. HsvColor(hue, saturation, value, alpha) has toColor(), toArgb(), copy(...) and HsvColor.from(color, previous), which keeps the previous hue for grays.
All parameters
| Parameter | Default | Description |
|---|---|---|
state or color + onColorChange |
Pick the overload you need | |
onColorChange |
null |
(state overload) Every change while dragging |
onColorChangeFinished |
null |
When a drag ends, a swatch is tapped or a hex code is entered |
style |
Wheel |
Wheel (disc + brightness slider), Ring (hue ring around a square), Square (panel + hue slider) |
enabled |
true |
Dimmed and ignores touch when false |
showAlpha / showPreview / showHexField |
true |
Toggle each section |
originalColor |
null |
Shown beside the current color in the preview; tap it to go back |
swatches |
ComposeColorPickerDefaults.Swatches |
16 everyday colors; emptyList() hides the row |
colors |
ComposeColorPickerDefaults.colors() |
thumbColor, thumbBorderColor, borderColor, checkerboardLightColor, checkerboardDarkColor, selectedSwatchBorderColor, fieldContainerColor, fieldTextColor, fieldLabelColor, fieldBorderColor, fieldFocusedBorderColor |
thumbSize / sliderHeight / ringWidth |
28 / 24 / 28.dp |
Thumb and track sizes |
panelShape |
RoundedCornerShape(16.dp) |
Corners of the saturation/brightness panel |
swatchSize / spacing |
36.dp / 16.dp |
Swatch size and gap between sections |
The picker fills the width its modifier gives it. Sliders are adjustable for screen readers ("Hue, 210Β°", "Opacity, 80%"), panels read their hex code, and swatches are radio buttons named by their hex code. State survives configuration changes and process death, hue included.
The :app module is a catalog with a demo screen per component, controls for their main options, and a light/dark toggle. Open the project in Android Studio and run app.
| Module | What's in it |
|---|---|
:components |
The library (com.nameisjayant.components), published to Maven Central |
:app |
The demo catalog (com.nameisjayant.composecomponents) |
The GIFs in this README are recorded off-device. A Robolectric test (app/src/test/.../docs/RecordComponentGifs.kt) renders each scene, drives it with real taps, drags and typing on a paused clock, and saves every frame. Then ffmpeg turns the frames into GIFs:
./scripts/record-gifs.sh # every scene β docs/gifs/*.gif
./scripts/record-gifs.sh ratingBar # just oneIssues and pull requests are welcome. When you add or change a component, please add a demo screen to :app, unit tests for its logic in components/src/test, a scene in RecordComponentGifs, and a section in this README.
Copyright 2026 Jayant Kumar
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.










