suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Components82
  • Overview
  • Accordion
  • Alert
  • Alert Dialog
  • Alert Sheet
  • Amount Input
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Bottom Bar
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Chip
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • FAB
  • Field
  • Fullscreen Menu
  • Haptics
  • Header
  • Hover Card
  • Icon
  • Input
  • Input Accessory Bar
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Keyboard Scroll View
  • Keypad
  • Label
  • Marker
  • Menubar
  • Message
  • Message Scroller
  • Native Select
  • Navigation Menu
  • Page Indicator
  • Pagination
  • Pin Pad
  • Popover
  • Progress
  • QR Scanner
  • Questionnaire
  • Radio Group
  • Resizable
  • Screen
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature Pad
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Tab Bar
  • Table
  • Tabs
  • Text
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Wheel Picker
  • Widget

suiss/ui · Component

Wheel Picker

A scrolling, 3D-curved wheel of options, like the iOS picker, for choosing values such as dates and times.

Wheel Picker is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

import {
  WheelPicker,
  WheelPickerColumn,
  WheelPickerItem,
} from "@/components/ui/wheel-picker"

const fruits = [
  "Apple",
  "Banana",
  "Cherry",
  "Grape",
  "Lemon",
  "Mango",
  "Orange",
  "Peach",
  "Pear",
  "Plum",
]

export default function WheelPickerDemo() {
  return (
    <div className="w-80 rounded-2xl bg-card p-4 shadow-md ring-1 ring-foreground/5 dark:ring-foreground/10">
      <WheelPicker>
        <WheelPickerColumn aria-label="Fruit" defaultValue="Lemon">
          {fruits.map((fruit) => (
            <WheelPickerItem key={fruit} value={fruit}>
              {fruit}
            </WheelPickerItem>
          ))}
        </WheelPickerColumn>
      </WheelPicker>
    </div>
  )
}

WheelPicker is the frame: it draws the highlighted selection band across the middle and lays out one or more WheelPickerColumn wheels side by side. Each column is a scrolling list of WheelPickerItem options that snap to the center, and rows curve away and fade as they move from the middle.

A column selects the item that comes to rest in the center after scrolling, or the item that's clicked. Use a wheel picker for touch-friendly choices from ordered lists, especially several related columns such as day, hour and minute. For a compact dropdown, use Select.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/wheel-picker

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add wheel-picker

Manual

  1. Add the cn helper.

  2. Copy the source into components/ui/wheel-picker.tsx:

components/ui/wheel-picker.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

type WheelPickerColumnProps = {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
}

type WheelPickerColumnContextProps = {
  value: string | undefined
  select: (value: string) => void
  getItemId: (value: string) => string
}
  1. Update the import paths to match your project.

Usage

import { WheelPicker, WheelPickerColumn, WheelPickerItem } from "@/components/ui/wheel-picker"
<div className="w-80 rounded-2xl bg-card p-4 shadow-md ring-1 ring-foreground/5 dark:ring-foreground/10">
  <WheelPicker>
    <WheelPickerColumn aria-label="Fruit" defaultValue="Lemon">
      {fruits.map((fruit) => (
        <WheelPickerItem key={fruit} value={fruit}>
          {fruit}
        </WheelPickerItem>
      ))}
    </WheelPickerColumn>
  </WheelPicker>
</div>

Each column manages its own value. Use defaultValue on WheelPickerColumn for an uncontrolled column, or value with onValueChange to control it. Give every WheelPickerItem a unique string value; its children are the visible label.

Set disabled on items that can't be chosen. Scrolling skips past them to the nearest enabled item, and the keyboard steps over them.

Examples

Disabled items

Disables booked time slots so scrolling, keys and clicks skip them.

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"])

export default function WheelPickerDisabledItems() {
  return (
    <div className="w-80 rounded-2xl bg-card p-4 shadow-md ring-1 ring-foreground/5 dark:ring-foreground/10">
      <WheelPicker>
        <WheelPickerColumn aria-label="Reservation time" defaultValue="19:00">
          {slots.map((slot) => (
            <WheelPickerItem
              key={slot}
              value={slot}
              disabled={booked.has(slot)}
            >
              {slot}
            </WheelPickerItem>
          ))}
        </WheelPickerColumn>
      </WheelPicker>
    </div>
  )
}

Date time

Combines controlled day, hour and minute columns into a date and time picker.

import * as React from "react"
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(props: React.ComponentProps<typeof WheelPicker>) {
  const [day, setDay] = React.useState(toDayValue(today))
  const [hour, setHour] = React.useState("19")
  const [minute, setMinute] = React.useState("30")

  return (
    <div className="flex flex-col gap-3">
      <WheelPicker {...props}>
        <WheelPickerColumn
          aria-label="Day"
          value={day}
          onValueChange={setDay}
          className="flex-1 *:justify-end"
        >
          {days.map(({ value, label }) => (
            <WheelPickerItem key={value} value={value}>
              {label}
            </WheelPickerItem>
          ))}
        </WheelPickerColumn>
        <WheelPickerColumn
          aria-label="Hour"
          value={hour}
          onValueChange={setHour}
        >
          {hours.map((value) => (
            <WheelPickerItem key={value} value={value}>
              {value}
            </WheelPickerItem>
          ))}
        </WheelPickerColumn>
        <WheelPickerColumn
          aria-label="Minute"
          value={minute}
          onValueChange={setMinute}
          className="flex-1 *:justify-start"
        >
          {minutes.map((value) => (
            <WheelPickerItem key={value} value={value}>
              {value}
            </WheelPickerItem>
          ))}
        </WheelPickerColumn>
      </WheelPicker>
      <p className="text-center text-sm text-muted-foreground tabular-nums">
        {day} {hour}:{minute}
      </p>
    </div>
  )
}

export default function WheelPickerDateTime() {
  return (
    <div className="w-80 rounded-2xl bg-card p-4 shadow-md ring-1 ring-foreground/5 dark:ring-foreground/10">
      <DateTimeExample />
    </div>
  )
}

API Reference

Anatomy3 parts
<WheelPicker>
<WheelPickerColumn>
<WheelPickerItem />
</WheelPickerColumn>
</WheelPicker>
01

WheelPicker

The frame that holds the columns and draws the selection band.

Also accepts the props of React.ComponentProps<"div">.

02

WheelPickerColumn

A scrolling, snapping wheel that holds one selected value.

PropTypeDefaultDescription
valuestring-The selected value, for controlled use.
defaultValuestring-The initial value when uncontrolled.
onValueChange(value: string) => void-Called when the column settles on a new value.

Also accepts the props of React.ComponentProps<"div">, WheelPickerColumnProps.

03

WheelPickerItem

A single option in a column.

PropTypeDefaultDescription
valuestringRequiredThe string value this item selects; it must be unique in its column.
disabledbooleanfalsePrevents the item from being selected.

Also accepts the props of React.ComponentProps<"div">.

Accessibility

  • Each column has the listbox role and is focusable; name it with aria-label. Items are option elements with aria-selected, and the column points to the selected item with aria-activedescendant.
  • With a column focused, the up and down arrow keys move to the previous or next enabled item, Page Up and Page Down move by five, and Home and End jump to the first and last enabled items.
  • Disabled items set aria-disabled and ignore clicks.
  • When reduced motion is preferred, the column scrolls to new values without smooth scrolling.

Notes

  • Row height comes from --wheel-picker-item-height (--spacing(8) by default), set on WheelPicker; the column shows about seven rows.
  • The curve is driven by scroll-driven animations where ViewTimeline is supported, and by a scroll handler elsewhere.
  • Parts carry data-slot attributes: wheel-picker, wheel-picker-column and wheel-picker-item. Items set data-value, data-selected and data-disabled.
  • The selection band uses bg-secondary.
iOS
Wheel Picker on iOS in light modeWheel Picker on iOS in dark mode
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-foreground/5 bg-card p-4 shadow-md dark:border-foreground/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(EsuissColor.card, in: shape)
      .overlay {
        shape.strokeBorder(EsuissColor.foreground.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.card, shape)
            .border(1.dp, colors.foreground.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 shadcn@latest add @esuiss-rn/wheel-picker

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add wheel-picker
$ pnpm dlx @esuiss/ui@latest add wheel-picker --platform swift
$ pnpm dlx @esuiss/ui@latest add wheel-picker --platform kotlin

The CLI also adds what it builds on: Text.

Manual

  1. Install the dependencies:
npx expo install cn
  1. Add the pieces it builds on: Text, and the primitives components/primitives/use-render.tsx, components/primitives/wheel-picker.tsx.

  2. Copy the source into components/ui/wheel-picker.tsx:

uniwind/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",
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn
  1. Add the pieces it builds on: Text, and the primitives components/primitives/use-render.tsx, components/primitives/wheel-picker.tsx.

  2. Copy the source into components/ui/wheel-picker.tsx:

nativewind/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",
  1. Update the import paths to match your project.
  1. Add the pieces it builds on: Text, and the primitives components/primitives/use-render.tsx, components/primitives/wheel-picker.tsx.

  2. Copy the source into components/ui/wheel-picker.tsx:

stylesheet/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",
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
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
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Components/WheelPicker.swift
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())
  }
}
swift/Components/Button.swift
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: 44
swift/Components/ButtonGroup.swift
import 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
}
swift/Components/DropdownMenu.swift
import 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 }
}
swift/Components/Field.swift
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 {
swift/Components/Haptics.swift
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>(
swift/Components/Overlay.swift
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()
  }
}
swift/Components/Select.swift
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] { [:] }
swift/Components/Separator.swift
import SwiftUI

public struct Separator: View {
  let orientation: Axis

  public init(_ orientation: Axis = .horizontal) {
    self.orientation = orientation
  }

  public var body: some View {
    Rectangle()
      .fill(EsuissColor.border)
      .frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
      .frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
      .accessibilityHidden(true)
  }
swift/Components/TextVariant.swift
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
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
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.DpOffset
kotlin/theme/Tokens.kt
package 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 background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/components/WheelPicker.kt
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.LazyColumn
kotlin/components/Content.kt
package 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 }
kotlin/components/FormSupport.kt
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Haptics.kt
package 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)
}
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { WheelPicker, WheelPickerColumn, WheelPickerItem } from "@/components/ui/wheel-picker"
<View className="w-80 rounded-2xl border border-foreground/5 bg-card p-4 shadow-md dark:border-foreground/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.

iOS
Wheel Picker (disabled items) on iOS in light modeWheel Picker (disabled items) on iOS in dark mode
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-foreground/5 bg-card p-4 shadow-md dark:border-foreground/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(EsuissColor.card, in: shape)
      .overlay {
        shape.strokeBorder(EsuissColor.foreground.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.card, shape)
            .border(1.dp, colors.foreground.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.

iOS
Wheel Picker (date time) on iOS in light modeWheel Picker (date time) on iOS in dark mode
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-muted-foreground tabular-nums">
        {day} {hour}:{minute}
      </Text>
    </View>
  )
}

export function WheelPickerDateTimeExample() {
  return (
    <View className="w-80 rounded-2xl border border-foreground/5 bg-card p-4 shadow-md dark:border-foreground/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(EsuissColor.card, in: shape)
      .overlay {
        shape.strokeBorder(EsuissColor.foreground.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(EsuissColor.mutedForeground)
        .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.card, shape)
            .border(1.dp, colors.foreground.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.mutedForeground,
            textAlign = TextAlign.Center,
        )
    }
}

API Reference

01

WheelPicker

Root row of columns with the selection highlight.

Also accepts the props of React.ComponentProps<typeof WheelPickerPrimitive.Root>.

02

WheelPickerColumn

Snapping scroll column that holds one value.

Also accepts the props of React.ComponentProps<typeof WheelPickerPrimitive.Column>.

03

WheelPickerItem

Single row in a column.

Also accepts the props of React.ComponentProps<typeof WheelPickerPrimitive.Item>.

Accessibility

  • Each column has the adjustable role 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 as Hour or Minute.
  • 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-secondary; items use text-xl text-foreground with tabular numbers and text-muted-foreground when disabled.
  • Light and dark colors follow the system appearance.
NextWidgetPreviousTooltip

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Disabled items
    • Date time
  4. 04API Reference
    • WheelPicker
    • WheelPickerColumn
    • WheelPickerItem
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Disabled items
    • Date time
  10. 10API Reference
    • WheelPicker
    • WheelPickerColumn
    • WheelPickerItem
  11. 11Accessibility
  12. 12Notes