Blocks
Compositions built from the prototypes.
Bigger pieces you can copy into an app, assembled from the wheel picker and the Ark UI components around it. They are React and Ark UI for now, styled with the design system recipes.
Time wheels
Hour, minute and AM/PM wheels side by side. The clock format follows the locale, so 24-hour locales get no AM/PM wheel, and the hour and minute wheels loop.
Hour
Minute
AM/PM
import { useState } from "react"
import { css } from "styled-system/css"
import { formatTime, type Time, TimeWheels } from "./time-wheels"
export function TimeWheelsDemo() {
const [time, setTime] = useState<Time>({ hour: 21, minute: 41 })
return (
<div className={css({ display: "grid", gap: "4", justifyItems: "center" })}>
<div className={css({ width: "56" })}>
<TimeWheels value={time} onValueChange={setTime} />
</div>
<output className={css({ color: "fg.muted", textStyle: "sm" })}>Selected: {formatTime(time)}</output>
</div>
)
}import { useMemo } from "react"
import { css } from "styled-system/css"
import { WheelColumn } from "./wheel-column"
export interface Time {
hour: number
minute: number
}
export interface TimeWheelsProps {
value: Time
onValueChange: (value: Time) => void
locale?: string
}
const pad = (value: number) => String(value).padStart(2, "0")
const getTwelveHour = (locale: string) =>
new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions().hourCycle?.startsWith("h1") ?? false
const getDayPeriods = (locale: string) => {
const format = new Intl.DateTimeFormat(locale, { hour: "numeric", hourCycle: "h12" })
return [9, 21].map(
(hour) =>
format.formatToParts(new Date(2020, 0, 1, hour)).find((part) => part.type === "dayPeriod")?.value ??
(hour < 12 ? "AM" : "PM"),
)
}
const toItems = (length: number, offset: number) =>
Array.from({ length }, (_, index) => ({ value: String(index + offset), label: pad(index + offset) }))
export function TimeWheels({ value, onValueChange, locale = "en-US" }: TimeWheelsProps) {
const twelveHour = getTwelveHour(locale)
const hours = useMemo(() => (twelveHour ? toItems(12, 1) : toItems(24, 0)), [twelveHour])
const minutes = useMemo(() => toItems(60, 0), [])
const periods = useMemo(() => {
const [am, pm] = getDayPeriods(locale)
return [
{ value: "am", label: am! },
{ value: "pm", label: pm! },
]
}, [locale])
const period = value.hour >= 12 ? "pm" : "am"
const displayHour = twelveHour ? value.hour % 12 || 12 : value.hour
const setHour = (hour: number, nextPeriod = period) =>
onValueChange({ ...value, hour: twelveHour ? (hour % 12) + (nextPeriod === "pm" ? 12 : 0) : hour })
return (
<div role="group" aria-label="Time" className={css({ display: "flex", gap: "1", width: "full" })}>
<WheelColumn
label="Hour"
items={hours}
value={String(displayHour)}
loop
onValueChange={(hour) => setHour(Number(hour))}
/>
<WheelColumn
label="Minute"
items={minutes}
value={String(value.minute)}
loop
onValueChange={(minute) => onValueChange({ ...value, minute: Number(minute) })}
/>
{twelveHour && (
<WheelColumn
label="AM/PM"
items={periods}
value={period}
onValueChange={(next) => setHour(displayHour, next)}
/>
)}
</div>
)
}
export function formatTime({ hour, minute }: Time, locale = "en-US") {
return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(
new Date(2020, 0, 1, hour, minute),
)
}import { WheelPicker } from "@isbatak/ark-wheel-picker/react"
import { wheelPicker as wheelPickerRecipe } from "styled-system/recipes"
const styles = wheelPickerRecipe()
export interface WheelColumnProps {
label: string
items: { value: string; label: string }[]
value: string
onValueChange: (value: string) => void
loop?: boolean
}
export function WheelColumn({ label, items, value, onValueChange, loop }: WheelColumnProps) {
return (
<WheelPicker.Root
items={items}
value={value}
loop={loop}
onValueChange={(details) => onValueChange(details.value)}
className={styles.root}
>
<WheelPicker.Label className={styles.label}>{label}</WheelPicker.Label>
<WheelPicker.Control className={styles.control}>
<WheelPicker.Viewport className={styles.viewport}>
<WheelPicker.ItemGroup className={styles.itemGroup}>
<WheelPicker.Context>
{(api) =>
api.rows.map(({ row, item }) => (
<WheelPicker.Item key={row} row={row} className={styles.item}>
{item.label}
</WheelPicker.Item>
))
}
</WheelPicker.Context>
</WheelPicker.ItemGroup>
</WheelPicker.Viewport>
<WheelPicker.Highlight className={styles.highlight} />
</WheelPicker.Control>
</WheelPicker.Root>
)
}Time picker dropdown
A button that shows the selected time and opens the wheels in a popover.
Select time
Hour
Minute
AM/PM
import { Portal } from "@ark-ui/react/portal"
import { Button } from "@isbatak/react-ui/button"
import { Popover } from "@isbatak/react-ui/popover"
import { useState } from "react"
import { formatTime, type Time, TimeWheels } from "./time-wheels"
export function TimePickerDropdown() {
const [time, setTime] = useState<Time>({ hour: 9, minute: 30 })
return (
<Popover.Root positioning={{ placement: "bottom" }}>
<Popover.Trigger asChild>
<Button variant="outline" minW="40" justifyContent="space-between">
{formatTime(time)}
</Button>
</Popover.Trigger>
<Portal>
<Popover.Positioner>
<Popover.Content w="64">
<Popover.Title srOnly>Select time</Popover.Title>
<TimeWheels value={time} onValueChange={setTime} />
</Popover.Content>
</Popover.Positioner>
</Portal>
</Popover.Root>
)
}import { useMemo } from "react"
import { css } from "styled-system/css"
import { WheelColumn } from "./wheel-column"
export interface Time {
hour: number
minute: number
}
export interface TimeWheelsProps {
value: Time
onValueChange: (value: Time) => void
locale?: string
}
const pad = (value: number) => String(value).padStart(2, "0")
const getTwelveHour = (locale: string) =>
new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions().hourCycle?.startsWith("h1") ?? false
const getDayPeriods = (locale: string) => {
const format = new Intl.DateTimeFormat(locale, { hour: "numeric", hourCycle: "h12" })
return [9, 21].map(
(hour) =>
format.formatToParts(new Date(2020, 0, 1, hour)).find((part) => part.type === "dayPeriod")?.value ??
(hour < 12 ? "AM" : "PM"),
)
}
const toItems = (length: number, offset: number) =>
Array.from({ length }, (_, index) => ({ value: String(index + offset), label: pad(index + offset) }))
export function TimeWheels({ value, onValueChange, locale = "en-US" }: TimeWheelsProps) {
const twelveHour = getTwelveHour(locale)
const hours = useMemo(() => (twelveHour ? toItems(12, 1) : toItems(24, 0)), [twelveHour])
const minutes = useMemo(() => toItems(60, 0), [])
const periods = useMemo(() => {
const [am, pm] = getDayPeriods(locale)
return [
{ value: "am", label: am! },
{ value: "pm", label: pm! },
]
}, [locale])
const period = value.hour >= 12 ? "pm" : "am"
const displayHour = twelveHour ? value.hour % 12 || 12 : value.hour
const setHour = (hour: number, nextPeriod = period) =>
onValueChange({ ...value, hour: twelveHour ? (hour % 12) + (nextPeriod === "pm" ? 12 : 0) : hour })
return (
<div role="group" aria-label="Time" className={css({ display: "flex", gap: "1", width: "full" })}>
<WheelColumn
label="Hour"
items={hours}
value={String(displayHour)}
loop
onValueChange={(hour) => setHour(Number(hour))}
/>
<WheelColumn
label="Minute"
items={minutes}
value={String(value.minute)}
loop
onValueChange={(minute) => onValueChange({ ...value, minute: Number(minute) })}
/>
{twelveHour && (
<WheelColumn
label="AM/PM"
items={periods}
value={period}
onValueChange={(next) => setHour(displayHour, next)}
/>
)}
</div>
)
}
export function formatTime({ hour, minute }: Time, locale = "en-US") {
return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(
new Date(2020, 0, 1, hour, minute),
)
}import { WheelPicker } from "@isbatak/ark-wheel-picker/react"
import { wheelPicker as wheelPickerRecipe } from "styled-system/recipes"
const styles = wheelPickerRecipe()
export interface WheelColumnProps {
label: string
items: { value: string; label: string }[]
value: string
onValueChange: (value: string) => void
loop?: boolean
}
export function WheelColumn({ label, items, value, onValueChange, loop }: WheelColumnProps) {
return (
<WheelPicker.Root
items={items}
value={value}
loop={loop}
onValueChange={(details) => onValueChange(details.value)}
className={styles.root}
>
<WheelPicker.Label className={styles.label}>{label}</WheelPicker.Label>
<WheelPicker.Control className={styles.control}>
<WheelPicker.Viewport className={styles.viewport}>
<WheelPicker.ItemGroup className={styles.itemGroup}>
<WheelPicker.Context>
{(api) =>
api.rows.map(({ row, item }) => (
<WheelPicker.Item key={row} row={row} className={styles.item}>
{item.label}
</WheelPicker.Item>
))
}
</WheelPicker.Context>
</WheelPicker.ItemGroup>
</WheelPicker.Viewport>
<WheelPicker.Highlight className={styles.highlight} />
</WheelPicker.Control>
</WheelPicker.Root>
)
}Time input
A date input limited to hour, minute and day period segments for typing, with the wheels in a popover for picking. Both edit the same value.
import { DateInput } from "@ark-ui/react/date-input"
import { Portal } from "@ark-ui/react/portal"
import { type DateValue, DateFormatter } from "@internationalized/date"
import { Button } from "@isbatak/react-ui/button"
import { Popover } from "@isbatak/react-ui/popover"
import { useMemo, useState } from "react"
import { css } from "styled-system/css"
import { type Time, TimeWheels } from "./time-wheels"
const segmentStyles = css({
px: "0.5",
borderRadius: "l1",
fontVariantNumeric: "tabular-nums",
outline: "none",
_focus: { bg: "colorPalette.solid", color: "colorPalette.contrast" },
"&[data-placeholder]": { color: "fg.muted" },
})
const controlStyles = css({
display: "inline-flex",
alignItems: "center",
gap: "2",
h: "10",
ps: "3",
pe: "1",
borderWidth: "1px",
borderRadius: "l2",
textStyle: "sm",
_focusWithin: { borderColor: "colorPalette.solid" },
})
const toTime = (value: DateValue | undefined): Time => ({
hour: value && "hour" in value ? value.hour : 0,
minute: value && "minute" in value ? value.minute : 0,
})
export function TimeInput({ locale = "en-US" }: { locale?: string }) {
const [value, setValue] = useState<DateValue[]>([])
const formatter = useMemo(
() => new DateFormatter(locale, { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }),
[locale],
)
return (
<DateInput.Root
locale={locale}
granularity="minute"
formatter={formatter}
shouldForceLeadingZeros
value={value}
onValueChange={(details) => setValue(details.value)}
>
<Popover.Root positioning={{ placement: "bottom-end" }} modal>
<DateInput.Context>
{(dateInput) => (
<>
<DateInput.Label className={css({ display: "block", mb: "2", textStyle: "sm", fontWeight: "medium" })}>
Time
</DateInput.Label>
<Popover.Anchor className={controlStyles}>
<DateInput.Control>
<DateInput.SegmentGroup className={css({ display: "flex" })}>
{dateInput.getSegments().map((segment, index) => (
<DateInput.Segment key={index} segment={segment} className={segmentStyles}>
{segment.text}
</DateInput.Segment>
))}
</DateInput.SegmentGroup>
</DateInput.Control>
<Popover.Trigger asChild>
<Button variant="ghost" size="sm" aria-label="Open time picker">
🕘
</Button>
</Popover.Trigger>
</Popover.Anchor>
<Portal>
<Popover.Positioner>
<Popover.Content w="64">
<Popover.Title srOnly>Select time</Popover.Title>
<TimeWheels
locale={locale}
value={toTime(value[0] ?? dateInput.placeholderValue)}
onValueChange={(time) => {
const current = value[0] ?? dateInput.placeholderValue
if ("hour" in current) setValue([current.set(time)])
}}
/>
</Popover.Content>
</Popover.Positioner>
</Portal>
</>
)}
</DateInput.Context>
<DateInput.HiddenInput />
</Popover.Root>
</DateInput.Root>
)
}import { useMemo } from "react"
import { css } from "styled-system/css"
import { WheelColumn } from "./wheel-column"
export interface Time {
hour: number
minute: number
}
export interface TimeWheelsProps {
value: Time
onValueChange: (value: Time) => void
locale?: string
}
const pad = (value: number) => String(value).padStart(2, "0")
const getTwelveHour = (locale: string) =>
new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions().hourCycle?.startsWith("h1") ?? false
const getDayPeriods = (locale: string) => {
const format = new Intl.DateTimeFormat(locale, { hour: "numeric", hourCycle: "h12" })
return [9, 21].map(
(hour) =>
format.formatToParts(new Date(2020, 0, 1, hour)).find((part) => part.type === "dayPeriod")?.value ??
(hour < 12 ? "AM" : "PM"),
)
}
const toItems = (length: number, offset: number) =>
Array.from({ length }, (_, index) => ({ value: String(index + offset), label: pad(index + offset) }))
export function TimeWheels({ value, onValueChange, locale = "en-US" }: TimeWheelsProps) {
const twelveHour = getTwelveHour(locale)
const hours = useMemo(() => (twelveHour ? toItems(12, 1) : toItems(24, 0)), [twelveHour])
const minutes = useMemo(() => toItems(60, 0), [])
const periods = useMemo(() => {
const [am, pm] = getDayPeriods(locale)
return [
{ value: "am", label: am! },
{ value: "pm", label: pm! },
]
}, [locale])
const period = value.hour >= 12 ? "pm" : "am"
const displayHour = twelveHour ? value.hour % 12 || 12 : value.hour
const setHour = (hour: number, nextPeriod = period) =>
onValueChange({ ...value, hour: twelveHour ? (hour % 12) + (nextPeriod === "pm" ? 12 : 0) : hour })
return (
<div role="group" aria-label="Time" className={css({ display: "flex", gap: "1", width: "full" })}>
<WheelColumn
label="Hour"
items={hours}
value={String(displayHour)}
loop
onValueChange={(hour) => setHour(Number(hour))}
/>
<WheelColumn
label="Minute"
items={minutes}
value={String(value.minute)}
loop
onValueChange={(minute) => onValueChange({ ...value, minute: Number(minute) })}
/>
{twelveHour && (
<WheelColumn
label="AM/PM"
items={periods}
value={period}
onValueChange={(next) => setHour(displayHour, next)}
/>
)}
</div>
)
}
export function formatTime({ hour, minute }: Time, locale = "en-US") {
return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(
new Date(2020, 0, 1, hour, minute),
)
}import { WheelPicker } from "@isbatak/ark-wheel-picker/react"
import { wheelPicker as wheelPickerRecipe } from "styled-system/recipes"
const styles = wheelPickerRecipe()
export interface WheelColumnProps {
label: string
items: { value: string; label: string }[]
value: string
onValueChange: (value: string) => void
loop?: boolean
}
export function WheelColumn({ label, items, value, onValueChange, loop }: WheelColumnProps) {
return (
<WheelPicker.Root
items={items}
value={value}
loop={loop}
onValueChange={(details) => onValueChange(details.value)}
className={styles.root}
>
<WheelPicker.Label className={styles.label}>{label}</WheelPicker.Label>
<WheelPicker.Control className={styles.control}>
<WheelPicker.Viewport className={styles.viewport}>
<WheelPicker.ItemGroup className={styles.itemGroup}>
<WheelPicker.Context>
{(api) =>
api.rows.map(({ row, item }) => (
<WheelPicker.Item key={row} row={row} className={styles.item}>
{item.label}
</WheelPicker.Item>
))
}
</WheelPicker.Context>
</WheelPicker.ItemGroup>
</WheelPicker.Viewport>
<WheelPicker.Highlight className={styles.highlight} />
</WheelPicker.Control>
</WheelPicker.Root>
)
}