Compose Multiplatform datetime picker implementation featuring highly customizable wheel pickers for date, time, and datetime selection.
- Customizable date order: DMY (Day-Month-Year), MDY (Month-Day-Year), or YMD (Year-Month-Day)
- Hide year picker: Set
yearsRange = nullto create day-month only or month-day only pickers - Custom year range: Limit year selection to specific ranges (e.g., next 10 years, last 50 years)
- Multiple month display styles: Full names, short names, or numeric format
- Auto-adapts to locale: Date order and month names automatically match the current locale
- CJK language support: Special handling for Chinese (年/月/日), Japanese (年/月/日), Korean (년/월/일) with customizable year/month/day suffixes
- Localized numerals: Supports Eastern Arabic numerals and other numeral systems
- Script-aware locale matching: A locale is matched first by its
language+scriptsubtags, then bylanguagealone, and finally falls back to English. Examples:uz-Arab→uz-Arab(direct script match),uz-Latn→uz(script not bundled, base language used),zh-Hant→zh(same). - Limitations: BCP 47
-u-*Unicode extension subtags (e.g.-u-nu-*numbering system,-u-ca-*calendar) are ignored — only thelanguageandscriptsubtags influence resolution.
Currently Supported Languages (30):
Arabic (العربية), Bengali (বাংলা), Chinese (中文), Czech (Čeština), Danish (Dansk), Dutch (Nederlands), English, Finnish (Suomi), French (Français), German (Deutsch), Greek (Ελληνικά), Hebrew (עברית), Hindi (हिन्दी), Indonesian (Bahasa Indonesia), Italian (Italiano), Japanese (日本語), Korean (한국어), Norwegian (Norsk — nb, no), Persian (فارسی), Polish (Polski), Portuguese (Português), Romanian (Română), Russian (Русский), Spanish (Español), Swedish (Svenska), Thai (ไทย), Turkish (Türkçe), Ukrainian (Українська), Uzbek (Oʻzbekcha / Ўзбекча / اۉزبېکچه — uz, uz-Cyrl, uz-Arab), Vietnamese (Tiếng Việt)
Contributions welcome: If you find any translation errors or want to add support for a new language, please open an issue or submit a pull request.
- Modifier-driven responsive sizing, fixed row count or fixed row height, inactive and selected text styles, and colors
- Customizable selector appearance (shape, color, border)
- Cylindrical barrel projection with a configurable rim angle
- Material Design integration
- 12-hour (AM/PM) and 24-hour formats
- Customizable time display
Day-Month Picker (No Year)
Perfect for birthdays, anniversaries, or recurring events:
WheelDatePicker(
startDate = LocalDate(2025, 6, 15),
yearsRange = null, // Hides the year picker
dateFormatter = dateFormatter(
dateOrder = DateOrder.DMY,
monthDisplayStyle = MonthDisplayStyle.FULL
)
) { snappedDate ->
// snappedDate.month and snappedDate.dayOfMonth
}Note: This uses the non-Composable
dateFormatter()overload that acceptsdateOrderparameter.
Month-Day Picker (US Format)
For US-style date input without year:
WheelDatePicker(
yearsRange = null,
dateFormatter = dateFormatter(
dateOrder = DateOrder.MDY,
monthDisplayStyle = MonthDisplayStyle.SHORT
)
) { snappedDate -> }Limited Year Range
Restrict year selection to a specific range:
// Only allow next 10 years
WheelDatePicker(
yearsRange = IntRange(2025, 2035),
dateFormatter = dateFormatter(
dateOrder = DateOrder.YMD
)
) { snappedDate -> }
// Only allow past 50 years (for birthdate)
val currentYear = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).year
WheelDatePicker(
yearsRange = IntRange(currentYear - 50, currentYear),
dateFormatter = dateFormatter(dateOrder = DateOrder.DMY)
) { snappedDate -> }Chinese/Japanese/Korean Format
With native year-month-day suffixes:
- Chinese: "2025年1月15日"
- Korean: "2025년1월15일"
- Japanese: "2025年1月15日"
WheelDatePicker(
dateFormatter = dateFormatter(
locale = Locale("zh"), // "zh" for Chinese, "ja" for Japanese, "ko" for Korean
monthDisplayStyle = MonthDisplayStyle.NUMERIC,
cjkSuffixConfig = CjkSuffixConfig.ShowAll
)
) { snappedDate -> }
// Without suffixes
WheelDatePicker(
dateFormatter = dateFormatter(
locale = Locale("zh"),
monthDisplayStyle = MonthDisplayStyle.NUMERIC,
cjkSuffixConfig = CjkSuffixConfig.HideAll
)
) { snappedDate -> }Note: This uses the Composable
dateFormatter()overload. Date order (YMD for CJK) is auto-detected from locale.
Numeric Month Display
Show months as numbers instead of names:
WheelDatePicker(
dateFormatter = dateFormatter(
dateOrder = DateOrder.DMY, // or MDY, YMD based on your preference
monthDisplayStyle = MonthDisplayStyle.NUMERIC
)
) { snappedDate -> }Picker size is controlled entirely through Modifier (since 1.4.0). When the caller does not
constrain an axis, the picker supplies its intrinsic default on that axis: 256.dp width for
WheelDatePicker/WheelDateTimePicker, 128.dp for WheelTimePicker/WheelTextPicker, and a
height that follows rows: ~42.7.dp per row for WheelRows.Count (the default Count(3) →
exactly 128.dp), or seven rows for WheelRows.Height. Larger counts grow the wheel instead of
squeezing rows.
WheelDatePicker { } // intrinsic 256 x 128.dp
WheelDatePicker(rows = WheelRows.Count(5)) { } // intrinsic height ~213.dp
WheelDatePicker(rows = WheelRows.Height(32.dp)) { } // intrinsic height 224.dp
WheelDatePicker(modifier = Modifier.fillMaxWidth()) { } // parent width, intrinsic height
WheelDatePicker(modifier = Modifier.height(200.dp)) { } // fixed height
WheelDatePicker(modifier = Modifier.size(300.dp, 160.dp)) { } // fixed size
WheelDatePicker(
modifier = Modifier
.widthIn(min = 240.dp, max = 400.dp)
.heightIn(min = 128.dp),
) { }Standard Compose constraint rules apply: fixed/min/max constraints from the modifier or the
parent override or clamp the intrinsic default, and pickers shrink to fit parents narrower than
their intrinsic width.
Migrating from size: DpSize (removed in 1.4.0, source-breaking only — hidden 1.3.x overloads
keep old binaries linking until the next major release):
// Before
WheelDatePicker(size = DpSize(300.dp, 160.dp)) { }
// After
WheelDatePicker(modifier = Modifier.size(300.dp, 160.dp)) { }
// Before workaround for responsive width
BoxWithConstraints(Modifier.fillMaxWidth()) {
WheelDatePicker(size = DpSize(maxWidth, 200.dp)) { }
}
// After
WheelDatePicker(modifier = Modifier.fillMaxWidth().height(200.dp)) { }For more than three rows, callers that depended on the old squeezed 128.dp total height should
state it explicitly with Modifier.height(128.dp).
Known limitation: the picker resolves its size via subcomposition and does not support intrinsic-measurement parents (
IntrinsicSize.Min/Maxwill throw). Pass an explicitwidth/heightinstead.
rows decides how the wheel divides its height. Together with the height and the barrel angle it
fixes the geometry of the drum, and you choose which quantity stays constant:
WheelDatePicker(rows = WheelRows.Count(5)) { } // exactly five rows from rim to rim
WheelDatePicker(rows = WheelRows.Height(32.dp)) { } // 32.dp rows, as many as fitWheelRows.Count(n)(defaultCount(3)) shows exactlynrows on the drum from rim to rim, the outermost foreshortened against the edge. The row height follows from the picker height, so a taller picker gets taller rows, and the selector is sized to the centered row. The drum always holds an odd number of rows so the selected row sits at the center with whole rows on both sides; an evennis laid out as the next odd number (Count(4)shows the same five rows asCount(5), whileCount(4).countstays4).WheelRows.Height(h)keeps every row and the selectorhtall whatever the picker height, and shows as many rows as fit; the number is usually fractional, with the outermost rows cut off at the rim exactly like a native iOS picker. Use it when you want the wheel to grow and shrink with its container without the text changing size. iOS uses 32pt rows in a 216pt picker.
Wheel rows are laid out on a vertical cylinder, giving the wheel the curved, drum-like appearance of a native picker.
How strongly the drum bends is controlled by rimAngle, the rotation in degrees of the drum
surface where it meets the top and bottom edges of the viewport. When you do not pass
barrelProperties, the picker uses WheelPickerDefaults.barrelPropertiesFor(rows). For
WheelRows.Count that adds 13° per row away from the center and caps at 70°, so a 3-row wheel
stays gently curved while a 7-row or taller wheel gets the full drum with every row readable. For
WheelRows.Height it is the full 90°, since no row count is promised. Pass
WheelPickerDefaults.barrelProperties(rimAngle)
with a value in [0, 90] to fix it. 90 shows the full half cylinder, matching iOS, at the cost
of the outermost rows becoming nearly unreadable against the rim; 0 is a flat, evenly spaced
wheel with no projection at all. fadeStrength is how quickly rows become transparent as they
leave the center. It must be non-negative and finite; there is no upper bound, because alpha is
clamped to [0, 1] and a value large enough to hide the nearest neighboring row already hides
everything past it. A row's alpha is 1 - fadeStrength · t², where t is its on-screen
distance from the center as a fraction of half the viewport height: 0 keeps every row opaque,
1 (default) reaches full transparency exactly at the edge, 4 reaches it halfway there, and
above about 10 even a tall drum leaves just the center row or two. The fade follows that
on-screen distance rather than the drum angle, so a gently curved 3-row wheel and a flat wheel
fade just like a full drum.
WheelDateTimePicker(
modifier = Modifier.height(240.dp),
rows = WheelRows.Count(11),
barrelProperties = WheelPickerDefaults.barrelProperties(rimAngle = 90f, fadeStrength = 0.8f),
) { snappedDateTime -> }The picker always occupies exactly the height you give it (or its intrinsic default); the
cylinder is sized to fit. With WheelRows.Count the outer rows are compressed, so the centered
row gets more room than height / count and the selector is sized to match it; with
WheelRows.Height the row and selector height are fixed and the angle only changes how many rows
are visible. Sizing, snapping, and callbacks are unaffected by the angle. The same parameter is
available on WheelDatePicker,
WheelTimePicker, and WheelTextPicker.
| Parameter | Type | Default | Description |
|---|---|---|---|
startDate |
LocalDate |
LocalDate.now() |
Initial selected date |
minDate |
LocalDate |
LocalDate.EPOCH |
Minimum selectable date |
maxDate |
LocalDate |
LocalDate.CYB3R_1N1T_ZOLL |
Maximum selectable date |
yearsRange |
IntRange? |
IntRange(minDate.year, maxDate.year) |
Year range to display. Set to null to hide year picker |
dateFormatter |
DateFormatter |
Auto-detected | Controls date order, month style, and CJK suffixes |
modifier |
Modifier |
Modifier |
Sizing and placement. Unconstrained axes use the intrinsic default (256.dp × 128.dp at 3 rows). See Sizing |
rows |
WheelRows |
WheelRows.Count(3) |
Row count (even counts lay out as the next odd), or a fixed row height. See Rows |
textStyle |
TextStyle |
MaterialTheme.typography.titleMedium |
Text styling for inactive items |
textColor |
Color |
LocalContentColor.current |
Text color for inactive items |
selectedTextStyle |
TextStyle |
textStyle |
Text styling for the selected (centered) item |
selectedTextColor |
Color |
textColor |
Text color for the selected (centered) item |
selectorProperties |
SelectorProperties |
Default | Selector appearance (shape, color, border) |
barrelProperties |
BarrelProperties |
WheelPickerDefaults.barrelPropertiesFor(rows) |
Rim angle and edge fade of the cylindrical row projection. See Barrel projection |
onSnappedDateChanged |
(LocalDate) -> Unit |
{} |
Callback fired during scrolling every time the snapped date changes (live updates) |
onSnappedDate |
(LocalDate) -> Unit |
{} |
Callback fired when scrolling settles on the final selected date |
onSnappedDateChangedvsonSnappedDate
onSnappedDateChangedis invoked continuously while the user is scrolling, each time a different item snaps into the selector. Useful for live previews, syncing UI, or tracking the in-flight value.onSnappedDateis invoked only once after the wheel comes to rest, representing the user's final choice. Use it for committing the selection (saving, navigating, etc.).The same pattern applies to
WheelTimePicker(onSnappedTimeChanged/onSnappedTime) andWheelDateTimePicker(onSnappedDateTimeChanged/onSnappedDateTime).
DateOrder (controls field arrangement):
DateOrder.DMY- Day, Month, Year (Europe, most of world)DateOrder.MDY- Month, Day, Year (US)DateOrder.YMD- Year, Month, Day (East Asia, ISO 8601)
MonthDisplayStyle:
MonthDisplayStyle.FULL- "January", "February", etc.MonthDisplayStyle.SHORT- "Jan", "Feb", etc.MonthDisplayStyle.NUMERIC- "1", "2", etc.
CjkSuffixConfig (for Chinese/Japanese/Korean):
CjkSuffixConfig.ShowAll- Shows year/month/day suffixes (Chinese/Japanese: 年/月/日, Korean: 년/월/일)CjkSuffixConfig.HideAll- Hides all suffixes- Custom:
CjkSuffixConfig(showYearSuffix = true, showMonthSuffix = false, ...)
| Parameter | Type | Default | Description |
|---|---|---|---|
startTime |
LocalTime |
LocalTime.now() |
Initial selected time |
minTime |
LocalTime |
LocalTime.MIN |
Minimum selectable time |
maxTime |
LocalTime |
LocalTime.MAX |
Maximum selectable time |
timeFormatter |
TimeFormatter |
Auto-detected | Controls 12/24 hour format (auto: AM/PM for en-US/GB, 24h for others) |
modifier |
Modifier |
Modifier |
Sizing and placement. Intrinsic default 128.dp wide (narrower than date picker). See Sizing |
| Other params | - | Same as WheelDatePicker |
rows, textStyle, textColor, selectedTextStyle, selectedTextColor, selectorProperties, barrelProperties, etc. |
TimeFormat:
TimeFormat.HOUR_24- 24-hour format (00:00 - 23:59)TimeFormat.AM_PM- 12-hour format with AM/PM
| Parameter | Type | Default | Description |
|---|---|---|---|
startDateTime |
LocalDateTime |
LocalDateTime.now() |
Initial selected date-time |
minDateTime |
LocalDateTime |
LocalDateTime.EPOCH |
Minimum selectable date-time |
maxDateTime |
LocalDateTime |
LocalDateTime.CYB3R_1N1T_ZOLL |
Maximum selectable date-time |
yearsRange |
IntRange? |
IntRange(minDateTime.year, maxDateTime.year) |
Year range to display. Set to null to hide year picker |
dateFormatter |
DateFormatter |
Auto-detected | Controls date order, month style, and CJK suffixes |
timeFormatter |
TimeFormatter |
Auto-detected | Controls 12/24 hour format |
modifier |
Modifier |
Modifier |
Sizing and placement. Intrinsic default 256.dp × 128.dp at 3 rows. See Sizing |
onSnappedDateTimeChanged |
(LocalDateTime) -> Unit |
{} |
Callback fired during scrolling every time the snapped date-time changes (live updates) |
onSnappedDateTime |
(LocalDateTime) -> Unit |
{} |
Callback fired when scrolling settles on the final selected date-time |
| Other params | - | Same as WheelDatePicker |
rows, textStyle, textColor, selectedTextStyle, selectedTextColor, selectorProperties, barrelProperties |
Each picker accepts selectedTextStyle / selectedTextColor to style the centered (snapped) item differently from inactive items. By default, they use the same values as textStyle / textColor.
WheelDatePicker(
textStyle = MaterialTheme.typography.titleMedium,
textColor = LocalContentColor.current,
selectedTextStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
selectedTextColor = MaterialTheme.colorScheme.primary,
)
selectedTextColoroverridesselectedTextStyle.color, mirroring howText(color = ...)overridesTextStyle.colorin Compose. The same parameters are available onWheelTimePicker,WheelDateTimePicker, and the internal text pickers.
In
WheelTimePickerandWheelDateTimePicker, the colon separator sits in the center row next to the selected hour and minute, so it followsselectedTextStyle/selectedTextColor. If you don't set theselected*params (defaults equaltextStyle/textColor), the colon is unchanged.
- Add the Maven Central repository if it is not already there:
repositories {
mavenCentral()
}- In Compose multiplatform projects, add a dependency to the commonMain source set dependencies:
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.github.darkokoa:datetime-wheel-picker:<version>")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
}
}
}
}
- To use the library in a single-platform project (such as Android project), add a dependency to the dependencies block:
dependencies {
implementation("io.github.darkokoa:datetime-wheel-picker:<version>")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
}- If your minimum Android platform's API level (minSdk) < 26, please enable Desugaring like this:
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
//...
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}Released under the Apache License, Version 2.0.