suiss/uim · React Native Component
Wheel Picker
A scrolling wheel for picking one value per column, with items that curve away from the center.
Wheel Picker is an open-source mobile component from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.


import { View } from "react-native"
import { WheelPicker, WheelPickerColumn, WheelPickerItem } from "@/components/ui/wheel-picker"
const fruits = [
"Apple",
"Banana",
"Cherry",
"Grape",
"Lemon",
"Mango",
"Orange",
"Peach",
"Pear",
"Plum",
]
export function WheelPickerExample() {
return (
<View className="w-80 rounded-2xl border border-label/5 bg-surface-raised p-4 shadow-md dark:border-label/10">
<WheelPicker>
<WheelPickerColumn accessibilityLabel="Fruit" defaultValue="Lemon">
{fruits.map((fruit) => (
<WheelPickerItem key={fruit} value={fruit}>
{fruit}
</WheelPickerItem>
))}
</WheelPickerColumn>
</WheelPicker>
</View>
)
}import EsuissUI
import SwiftUI
struct WheelPickerExample: View {
var body: some View {
WheelPickerFrame {
FruitDemo()
}
}
}
struct WheelPickerFrame<Content: View>: View {
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
content()
.padding(16)
.frame(width: 320)
.background(Color.surfaceRaised, in: shape)
.overlay {
shape.strokeBorder(Color.label.opacity(colorScheme == .dark ? 0.1 : 0.05))
}
.shadow(Shadow.md)
}
}
let wheelFruits = ["Apple", "Banana", "Cherry", "Grape", "Lemon", "Mango", "Orange", "Peach", "Pear", "Plum"]
struct FruitDemo: View {
@State private var fruit = "Lemon"
var body: some View {
WheelPicker {
WheelPickerColumn(selection: $fruit) {
ForEach(wheelFruits, id: \.self) { item in
WheelPickerItem(verbatim: item, value: item)
}
}
.accessibilityLabel(Text("Fruit"))
}
}
}import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.WheelPicker
import com.esuiss.ui.components.WheelPickerColumn
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.shadow
@Composable
fun WheelPickerExample() {
WheelPickerFrame { WheelPickerFruits() }
}
val wheelPickerFruits = listOf(
"Apple",
"Banana",
"Cherry",
"Grape",
"Lemon",
"Mango",
"Orange",
"Peach",
"Pear",
"Plum",
)
@Composable
fun WheelPickerFrame(content: @Composable () -> Unit) {
val colors = EsuissTheme.colors
val shape = RoundedCornerShape(Radius.xl2)
Box(
Modifier
.width(320.dp)
.shadow(Shadows.md, shape)
.background(colors.surfaceRaised, shape)
.border(1.dp, colors.label.copy(alpha = if (colors.isDark) 0.1f else 0.05f), shape)
.padding(16.dp),
) {
content()
}
}
@Composable
fun WheelPickerFruits() {
var value by rememberSaveable { mutableStateOf("Lemon") }
WheelPicker(Modifier.fillMaxWidth()) {
WheelPickerColumn(value, { value = it }, label = "Fruit") {
wheelPickerFruits.forEach { WheelPickerItem(it, it) }
}
}
}WheelPicker lays out one or more WheelPickerColumn components side by side behind a shared highlight band. Each column is a snapping ScrollView; items above and below the center are scaled, moved and faded with React Native Animated interpolations to look like a rotating wheel.
Use it for times, dates and other values picked from a short, ordered list, often in a sheet or popover.
Installation
$ pnpm dlx @esuiss/uim@latest add wheel-picker$ pnpm dlx @esuiss/uim@latest add wheel-picker --platform swift$ pnpm dlx @esuiss/uim@latest add wheel-picker --platform kotlinThe CLI also adds what it builds on: Text.
Manual
- Install the dependencies:
npx expo install cn-
Add the pieces it builds on: Text, and the primitives
components/primitives/use-render.tsx,components/primitives/wheel-picker.tsx. -
Copy the source into
components/ui/wheel-picker.tsx:
import { cn } from "cn"
import type * as React from "react"
import { resolveClassName } from "@/components/primitives/use-render"
import * as WheelPickerPrimitive from "@/components/primitives/wheel-picker"
import { Text, TextClassContext } from "@/components/ui/text"
function WheelPicker({
className,
children,
...props
}: React.ComponentProps<typeof WheelPickerPrimitive.Root>) {
return (
<WheelPickerPrimitive.Root
className={(state) =>
cn(
"relative flex-row items-stretch justify-center",- Update the import paths to match your project.
- Install the dependencies:
npx expo install cn-
Add the pieces it builds on: Text, and the primitives
components/primitives/use-render.tsx,components/primitives/wheel-picker.tsx. -
Copy the source into
components/ui/wheel-picker.tsx:
import { cn } from "cn"
import type * as React from "react"
import { resolveClassName } from "@/components/primitives/use-render"
import * as WheelPickerPrimitive from "@/components/primitives/wheel-picker"
import { Text, TextClassContext } from "@/components/ui/text"
function WheelPicker({
className,
children,
...props
}: React.ComponentProps<typeof WheelPickerPrimitive.Root>) {
return (
<WheelPickerPrimitive.Root
className={(state) =>
cn(
"relative flex-row items-stretch justify-center",- Update the import paths to match your project.
-
Add the pieces it builds on: Text, and the primitives
components/primitives/use-render.tsx,components/primitives/wheel-picker.tsx. -
Copy the source into
components/ui/wheel-picker.tsx:
import type * as React from "react"
import {
type StyleProp,
StyleSheet,
type TextStyle,
type ViewStyle,
} from "react-native"
import { resolveStyle } from "@/components/primitives/use-render"
import * as WheelPickerPrimitive from "@/components/primitives/wheel-picker"
import { Text, TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"
const styles = StyleSheet.create({
root: {
position: "relative",
flexDirection: "row",- Update the import paths to match your project.
-
Target iOS 17 or later.
-
Add the Swift package to your app:
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")- Copy the theme files every view uses:
import SwiftUI
import UIKit
struct RGBA: Sendable {
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat
init(_ hex: UInt32, _ alpha: CGFloat = 1) {
red = CGFloat((hex >> 16) & 0xFF) / 255
green = CGFloat((hex >> 8) & 0xFF) / 255
blue = CGFloat(hex & 0xFF) / 255
self.alpha = alpha
}import SwiftUI
extension Color {
public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))- Copy the source and every file it uses:
import SwiftUI
struct WheelPickerEntry: Equatable {
let value: AnyHashable
let disabled: Bool
let text: Text?
}
struct WheelPickerEntriesKey: PreferenceKey {
static var defaultValue: [WheelPickerEntry] { [] }
static func reduce(value: inout [WheelPickerEntry], nextValue: () -> [WheelPickerEntry]) {
value.append(contentsOf: nextValue())
}
}import SwiftUI
public struct VariantButtonStyle: ButtonStyle {
public enum Variant: CaseIterable, Sendable {
case `default`, outline, secondary, tinted, ghost, plain, destructive, link
}
public enum Size: CaseIterable, Sendable {
case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl
var height: CGFloat {
switch self {
case .xs, .iconXs: 24
case .sm, .iconSm: 32
case .default, .icon: 36
case .lg, .iconLg: 44import SwiftUI
extension EnvironmentValues {
@Entry var buttonGroup: Axis? = nil
@Entry var buttonGroupSize: CGSize = .zero
}
enum ButtonGroupSpace: Hashable {
case group
}
struct ButtonGroupFrame {
let bounds: Anchor<CGRect>
let outlined: Bool
}import PhosphorSwift
import SwiftUI
extension Shadow {
static let control = Shadow(color: .black.opacity(0.08), radius: 0.5, y: 0.5)
static let thumb = Shadow(color: .black.opacity(0.12), radius: 1.5, y: 1)
}
struct SelectionIndicator<Mark: View>: View {
let checked: Bool
let pressed: Bool
let cornerRadius: CGFloat
let mark: Mark
@Environment(\.isEnabled) private var isEnabled
@Environment(\.isInvalid) private var invalidimport PhosphorSwift
import SwiftUI
enum MenuPresentation: Sendable {
case system, popup
}
extension EnvironmentValues {
@Entry var menuPresentation: MenuPresentation = .system
@Entry var menuRadio = MenuRadioContext()
}
struct MenuRadioContext: @unchecked Sendable {
var selected: AnyHashable?
var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}import SwiftUI
extension EnvironmentValues {
@Entry public var isInvalid = false
@Entry var fieldInset = false
@Entry var fieldGroupInset = false
@Entry var fieldSetInset = false
}
extension View {
public func invalid(_ isInvalid: Bool = true) -> some View {
environment(\.isInvalid, isInvalid)
}
}
public enum FieldOrientation: CaseIterable, Sendable {import SwiftUI
extension EnvironmentValues {
@Entry public var hapticsEnabled = true
}
extension View {
public func haptics(_ enabled: Bool) -> some View {
environment(\.hapticsEnabled, enabled)
}
func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
}
func hapticFeedback<Trigger: Equatable>(import PhosphorSwift
import Observation
import SwiftUI
public enum PopupAlignment: Sendable, CaseIterable {
case start, center, end
}
struct OverlayDismiss: Sendable {
let action: @MainActor @Sendable () -> Void
@MainActor func callAsFunction() {
action()
}
}import PhosphorSwift
import SwiftUI
struct SelectContext: @unchecked Sendable {
var selected: AnyHashable?
var registering = false
var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}
extension EnvironmentValues {
@Entry var select = SelectContext()
}
struct SelectLabelsKey: PreferenceKey {
static var defaultValue: [AnyHashable: Text] { [:] }import SwiftUI
public struct Separator: View {
let orientation: Axis
public init(_ orientation: Axis = .horizontal) {
self.orientation = orientation
}
public var body: some View {
Rectangle()
.fill(Color.separator)
.frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
.frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
.accessibilityHidden(true)
}import SwiftUI
public enum TextVariant: CaseIterable, Sendable {
case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted
var style: TextStyle {
switch self {
case .h1: .xl3
case .h2: .xl2
case .h3: .xl
case .h4, .lead, .large: .lg
case .code, .small, .muted: .sm
default: .base
}
}- Add the files to your app target.
- Set
minSdkto 24 or higher and add the dependencies:
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")- Copy the theme files every view uses:
package com.esuiss.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffsetpackage com.esuiss.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Immutable
data class Colors(
val surface: Color,
val surfaceSecondary: Color,
val surfaceTertiary: Color,
val surfaceRaised: Color,
val label: Color,
val labelSecondary: Color,
val labelTertiary: Color,
val labelQuaternary: Color,- Copy the source and every file it uses:
package com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.SnapPosition
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumnpackage com.esuiss.ui.components
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val LocalContentStyle = compositionLocalOf { TextStyle.Default }
val LocalContentColor = compositionLocalOf { Color.Unspecified }
val LocalIconSize = compositionLocalOf { 20.dp }package com.esuiss.ui.components
import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Sizepackage com.esuiss.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
val LocalHapticsEnabled = staticCompositionLocalOf { true }
@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}- Update the
com.esuiss.uipackage names to match your project.
Usage
import { WheelPicker, WheelPickerColumn, WheelPickerItem } from "@/components/ui/wheel-picker"<View className="w-80 rounded-2xl border border-label/5 bg-surface-raised p-4 shadow-md dark:border-label/10">
<WheelPicker>
<WheelPickerColumn accessibilityLabel="Fruit" defaultValue="Lemon">
{fruits.map((fruit) => (
<WheelPickerItem key={fruit} value={fruit}>
{fruit}
</WheelPickerItem>
))}
</WheelPickerColumn>
</WheelPicker>
</View>Give each column a defaultValue, or value with onValueChange, and fill it with WheelPickerItem components that each have a unique value. Plain string or number children are wrapped in single-line Text.
The value is set when scrolling settles or when an item is pressed. Disabled items are skipped: if the wheel stops on one, it moves to the nearest enabled item.
Combine several columns for compound values such as day, hour and minute, and use className on columns and items to control width and alignment. Set itemHeight on WheelPicker to change the row height.
Examples
Disabled items
A time list where booked slots are disabled and skipped.


import { View } from "react-native"
import { WheelPicker, WheelPickerColumn, WheelPickerItem } from "@/components/ui/wheel-picker"
const slots = Array.from({ length: 11 }, (_, index) => {
const minutes = 18 * 60 + index * 30
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`
})
const booked = new Set(["19:30", "20:00", "20:30"])
const minutes = Array.from({ length: 12 }, (_, step) =>
String(step * 5).padStart(2, "0")
)
export function WheelPickerDisabledItemsExample() {
return (
<View className="w-80 rounded-2xl border border-label/5 bg-surface-raised p-4 shadow-md dark:border-label/10">
<WheelPicker>
<WheelPickerColumn
accessibilityLabel="Reservation time"
defaultValue="19:00"
>
{slots.map((slot) => (
<WheelPickerItem key={slot} value={slot} disabled={booked.has(slot)}>
{slot}
</WheelPickerItem>
))}
</WheelPickerColumn>
</WheelPicker>
</View>
)
}import EsuissUI
import SwiftUI
struct WheelPickerDisabledItemsExample: View {
var body: some View {
WheelPickerFrame {
SlotsDemo()
}
}
}
struct WheelPickerFrame<Content: View>: View {
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
content()
.padding(16)
.frame(width: 320)
.background(Color.surfaceRaised, in: shape)
.overlay {
shape.strokeBorder(Color.label.opacity(colorScheme == .dark ? 0.1 : 0.05))
}
.shadow(Shadow.md)
}
}
let wheelSlots: [String] = (0..<11).map { index in
let minutes = 18 * 60 + index * 30
return String(format: "%d:%02d", minutes / 60, minutes % 60)
}
let wheelBooked: Set<String> = ["19:30", "20:00", "20:30"]
struct SlotsDemo: View {
@State private var slot = "19:00"
var body: some View {
WheelPicker {
WheelPickerColumn(selection: $slot) {
ForEach(wheelSlots, id: \.self) { item in
WheelPickerItem(verbatim: item, value: item)
.disabled(wheelBooked.contains(item))
}
}
.accessibilityLabel(Text("Reservation time"))
}
}
}import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.WheelPicker
import com.esuiss.ui.components.WheelPickerColumn
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.shadow
@Composable
fun WheelPickerDisabledItemsExample() {
WheelPickerFrame { WheelPickerDisabledItems() }
}
val wheelPickerSlots = List(11) { index ->
val minutes = 18 * 60 + index * 30
"${minutes / 60}:${(minutes % 60).toString().padStart(2, '0')}"
}
val wheelPickerBooked = setOf("19:30", "20:00", "20:30")
@Composable
fun WheelPickerFrame(content: @Composable () -> Unit) {
val colors = EsuissTheme.colors
val shape = RoundedCornerShape(Radius.xl2)
Box(
Modifier
.width(320.dp)
.shadow(Shadows.md, shape)
.background(colors.surfaceRaised, shape)
.border(1.dp, colors.label.copy(alpha = if (colors.isDark) 0.1f else 0.05f), shape)
.padding(16.dp),
) {
content()
}
}
@Composable
fun WheelPickerDisabledItems() {
var value by rememberSaveable { mutableStateOf("19:00") }
WheelPicker(Modifier.fillMaxWidth()) {
WheelPickerColumn(value, { value = it }, label = "Reservation time") {
wheelPickerSlots.forEach { WheelPickerItem(it, it, enabled = it !in wheelPickerBooked) }
}
}
}Date time
Controlled day, hour and minute columns combined into one value.


import * as React from "react"
import { View } from "react-native"
import { Text } from "@/components/ui/text"
import { WheelPicker, WheelPickerColumn, WheelPickerItem } from "@/components/ui/wheel-picker"
function toDayValue(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
const today = new Date(2026, 9, 4)
const days = Array.from({ length: 15 }, (_, index) => {
const date = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + index - 7
)
const label =
index === 7
? "Today"
: date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})
return { value: toDayValue(date), label }
})
const hours = Array.from({ length: 24 }, (_, hour) =>
String(hour).padStart(2, "0")
)
const minutes = Array.from({ length: 12 }, (_, step) =>
String(step * 5).padStart(2, "0")
)
function DateTimeExample() {
const [day, setDay] = React.useState(toDayValue(today))
const [hour, setHour] = React.useState("19")
const [minute, setMinute] = React.useState("30")
return (
<View className="gap-3">
<WheelPicker>
<WheelPickerColumn
accessibilityLabel="Day"
value={day}
onValueChange={setDay}
className="grow-[1.6]"
>
{days.map(({ value, label }) => (
<WheelPickerItem key={value} value={value} className="justify-end">
{label}
</WheelPickerItem>
))}
</WheelPickerColumn>
<WheelPickerColumn
accessibilityLabel="Hour"
value={hour}
onValueChange={setHour}
>
{hours.map((value) => (
<WheelPickerItem key={value} value={value}>
{value}
</WheelPickerItem>
))}
</WheelPickerColumn>
<WheelPickerColumn
accessibilityLabel="Minute"
value={minute}
onValueChange={setMinute}
className="grow"
>
{minutes.map((value) => (
<WheelPickerItem
key={value}
value={value}
className="justify-start"
>
{value}
</WheelPickerItem>
))}
</WheelPickerColumn>
</WheelPicker>
<Text className="text-center text-sm text-label-secondary tabular-nums">
{day} {hour}:{minute}
</Text>
</View>
)
}
export function WheelPickerDateTimeExample() {
return (
<View className="w-80 rounded-2xl border border-label/5 bg-surface-raised p-4 shadow-md dark:border-label/10">
<DateTimeExample />
</View>
)
}import EsuissUI
import SwiftUI
struct WheelPickerDateTimeExample: View {
var body: some View {
WheelPickerFrame {
DateTimeDemo()
}
}
}
struct WheelPickerFrame<Content: View>: View {
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
content()
.padding(16)
.frame(width: 320)
.background(Color.surfaceRaised, in: shape)
.overlay {
shape.strokeBorder(Color.label.opacity(colorScheme == .dark ? 0.1 : 0.05))
}
.shadow(Shadow.md)
}
}
struct WheelDay {
let value: String
let label: String
}
let wheelDays: [WheelDay] = {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC") ?? .current
let today = calendar.date(from: DateComponents(year: 2026, month: 10, day: 4)) ?? .now
let label = Date.FormatStyle(timeZone: calendar.timeZone)
.weekday(.abbreviated)
.month(.abbreviated)
.day()
.locale(Locale(identifier: "en_US"))
return (0..<15).map { index in
let date = calendar.date(byAdding: .day, value: index - 7, to: today) ?? today
let parts = calendar.dateComponents([.year, .month, .day], from: date)
let value = String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
return WheelDay(value: value, label: index == 7 ? "Today" : date.formatted(label))
}
}()
let wheelHours = (0..<24).map { String(format: "%02d", $0) }
let wheelMinutes = (0..<12).map { String(format: "%02d", $0 * 5) }
struct DateTimeDemo: View {
@State private var day = "2026-10-04"
@State private var hour = "19"
@State private var minute = "30"
var body: some View {
VStack(spacing: 12) {
WheelPicker {
WheelPickerColumn(selection: $day, alignment: .trailing) {
ForEach(wheelDays, id: \.value) { item in
WheelPickerItem(verbatim: item.label, value: item.value)
}
}
.frame(minWidth: 150, maxWidth: .infinity, alignment: .trailing)
.accessibilityLabel(Text("Day"))
WheelPickerColumn(selection: $hour) {
ForEach(wheelHours, id: \.self) { item in
WheelPickerItem(verbatim: item, value: item)
}
}
.accessibilityLabel(Text("Hour"))
WheelPickerColumn(selection: $minute, alignment: .leading) {
ForEach(wheelMinutes, id: \.self) { item in
WheelPickerItem(verbatim: item, value: item)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityLabel(Text("Minute"))
}
Text(verbatim: "\(day) \(hour):\(minute)")
.textStyle(.sm)
.monospacedDigit()
.foregroundStyle(Color.labelSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
}import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.WheelPicker
import com.esuiss.ui.components.WheelPickerColumn
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@Composable
fun WheelPickerDateTimeExample() {
WheelPickerFrame { WheelPickerDateTime() }
}
class WheelPickerDay(val value: String, val label: String)
val wheelPickerDays: List<WheelPickerDay> = run {
val format = SimpleDateFormat("EEE, MMM d", Locale.US)
val key = SimpleDateFormat("yyyy-MM-dd", Locale.US)
List(15) { index ->
val calendar = Calendar.getInstance(Locale.US)
calendar.clear()
calendar.set(2026, Calendar.OCTOBER, 4 + index - 7)
WheelPickerDay(key.format(calendar.time), if (index == 7) "Today" else format.format(calendar.time))
}
}
val wheelPickerHours = List(24) { it.toString().padStart(2, '0') }
val wheelPickerMinutes = List(12) { (it * 5).toString().padStart(2, '0') }
@Composable
fun WheelPickerFrame(content: @Composable () -> Unit) {
val colors = EsuissTheme.colors
val shape = RoundedCornerShape(Radius.xl2)
Box(
Modifier
.width(320.dp)
.shadow(Shadows.md, shape)
.background(colors.surfaceRaised, shape)
.border(1.dp, colors.label.copy(alpha = if (colors.isDark) 0.1f else 0.05f), shape)
.padding(16.dp),
) {
content()
}
}
@Composable
fun WheelPickerDateTime() {
var day by rememberSaveable { mutableStateOf(wheelPickerDays[7].value) }
var hour by rememberSaveable { mutableStateOf("19") }
var minute by rememberSaveable { mutableStateOf("30") }
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
WheelPicker(Modifier.fillMaxWidth()) {
WheelPickerColumn(
day,
{ day = it },
Modifier.weight(1.6f),
label = "Day",
horizontalAlignment = Alignment.End,
) {
wheelPickerDays.forEach { WheelPickerItem(it.value, it.label) }
}
WheelPickerColumn(hour, { hour = it }, label = "Hour") {
wheelPickerHours.forEach { WheelPickerItem(it, it) }
}
WheelPickerColumn(
minute,
{ minute = it },
Modifier.weight(1f),
label = "Minute",
horizontalAlignment = Alignment.Start,
) {
wheelPickerMinutes.forEach { WheelPickerItem(it, it) }
}
}
Text(
"$day $hour:$minute",
modifier = Modifier.fillMaxWidth(),
style = Typography.sm.copy(fontFeatureSettings = "tnum"),
color = EsuissTheme.colors.labelSecondary,
textAlign = TextAlign.Center,
)
}
}API Reference
WheelPicker
Root row of columns with the selection highlight.
React.ComponentProps<typeof WheelPickerPrimitive.Root>WheelPickerColumn
Snapping scroll column that holds one value.
React.ComponentProps<typeof WheelPickerPrimitive.Column>WheelPickerItem
Single row in a column.
React.ComponentProps<typeof WheelPickerPrimitive.Item>Accessibility
- Each column has the
adjustablerole and announces the selected item's label as its value. - VoiceOver and TalkBack users swipe up or down to move to the next or previous enabled item.
- Give each column an
accessibilityLabel, such asHourorMinute. - The highlight band is hidden from screen readers.
Notes
- A selection haptic plays when the value changes, if a haptics adapter is provided through the haptics context.
- The highlight uses
bg-control; items usetext-xl text-labelwith tabular numbers andtext-label-tertiarywhen disabled. - Light and dark colors follow the system appearance.