Try it with a scroll

Explore wheel and trackpad input, from a ready-made Embla integration to the gesture data behind it. Every demo includes code to get started.

Looping time picker

An iOS-style picker with two independent wheels. Scroll or drag to choose a time; the hours and minutes wrap around in both directions and snap into place.

Hours
Scroll or drag to select. Up and Down change by one, Page Up and Page Down by five. Home selects the first value, End the last. Values loop.
Minutes
Scroll or drag to select. Up and Down change by one, Page Up and Page Down by five. Home selects the first value, End the last. Values loop.

Selected time 09:41

Scroll or drag either column. Use ↑ / ↓ when a column is focused.

View code: Looping time picker

Install the Embla integration in a React project. It includes wheel-gestures as a dependency; there is no need to install the core library separately for this example.

npm install embla-carousel@8.6.0 embla-carousel-react@8.6.0 embla-carousel-wheel-gestures@8.0.5

Save these two files together, then render <TimePicker />. CSS Modules with Sass support are required.

npm install --save-dev sass

TimePicker.tsx

/*
MIT License

Copyright (c) David Jerleke.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

import type { EmblaCarouselType } from 'embla-carousel'
import useEmblaCarousel from 'embla-carousel-react'
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'
import React, { KeyboardEvent, useEffect, useId, useState } from 'react'

import c from './TimePicker.module.scss'

// The 3D wheel presentation is inspired by Embla's MIT-licensed iOS picker:
// https://www.embla-carousel.com/docs/examples/predefined#ios-style-picker-loop
const ITEM_HEIGHT = 40
const ITEM_ANGLE = 20
const RADIUS = ITEM_HEIGHT / 2 / Math.tan((ITEM_ANGLE * Math.PI) / 360)
const formatValue = (value: number) => String(value).padStart(2, '0')

type ColumnProps = {
  label: string
  count: number
  initialValue: number
  onChange: (value: number) => void
}

function PickerColumn({ label, count, initialValue, onChange }: ColumnProps) {
  const [scene, setScene] = useState<HTMLDivElement | null>(null)
  const [selected, setSelected] = useState(initialValue)
  const [reducedMotion, setReducedMotion] = useState(false)
  const labelId = useId()
  const hintId = useId()
  const [emblaRef, emblaApi] = useEmblaCarousel(
    {
      axis: 'y',
      loop: true,
      skipSnaps: true,
      containScroll: false,
      watchSlides: false,
      watchFocus: false,
      startIndex: initialValue,
      duration: reducedMotion ? 0 : 25,
    },
    [WheelGesturesPlugin({ target: scene ?? undefined })]
  )

  useEffect(() => {
    const preference = window.matchMedia('(prefers-reduced-motion: reduce)')
    const update = () => setReducedMotion(preference.matches)
    update()
    preference.addEventListener('change', update)
    return () => preference.removeEventListener('change', update)
  }, [])

  useEffect(() => {
    if (!emblaApi) return

    const renderWheel = (api: EmblaCarouselType) => {
      const position = api.scrollProgress() * count
      api.slideNodes().forEach((slide, index) => {
        // Use the nearest copy of each item, including across the loop boundary.
        const distance = ((((index - position + count / 2) % count) + count) % count) - count / 2
        slide.style.opacity = Math.abs(distance) < 4.5 ? '1' : '0'
        slide.style.transform = reducedMotion
          ? `translateY(calc(${-index * 100}% + ${distance * ITEM_HEIGHT}px))`
          : `translateY(${-index * 100}%) rotateX(${-distance * ITEM_ANGLE}deg) translateZ(${RADIUS}px)`
      })
    }
    const select = (api: EmblaCarouselType) => setSelected(api.selectedScrollSnap())
    const settle = (api: EmblaCarouselType) => onChange(api.selectedScrollSnap())
    const initialize = (api: EmblaCarouselType) => {
      // Embla owns input, physics and snapping; we replace only its flat transforms.
      const { translate, slideLooper } = api.internalEngine()
      translate.clear()
      translate.toggleActive(false)
      slideLooper.loopPoints.forEach(({ translate: slideTranslate }) => {
        slideTranslate.clear()
        slideTranslate.toggleActive(false)
      })
      api.containerNode().style.transform = reducedMotion ? 'none' : `translateZ(${-RADIUS}px)`
      renderWheel(api)
      select(api)
      settle(api)
    }

    initialize(emblaApi)
    emblaApi.on('scroll', renderWheel).on('select', select).on('settle', settle).on('reInit', initialize)
    return () => {
      emblaApi.off('scroll', renderWheel).off('select', select).off('settle', settle).off('reInit', initialize)
    }
  }, [emblaApi, count, onChange, reducedMotion])

  const changeBy = (step: number) => {
    if (!emblaApi) return
    emblaApi.scrollTo((emblaApi.selectedScrollSnap() + step + count) % count, reducedMotion)
    onChange(emblaApi.selectedScrollSnap())
  }

  const handleKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!emblaApi) return
    switch (event.key) {
      case 'ArrowUp':
        changeBy(1)
        break
      case 'ArrowDown':
        changeBy(-1)
        break
      case 'PageUp':
        changeBy(5)
        break
      case 'PageDown':
        changeBy(-5)
        break
      case 'Home':
        emblaApi.scrollTo(0, reducedMotion)
        onChange(0)
        break
      case 'End':
        emblaApi.scrollTo(count - 1, reducedMotion)
        onChange(count - 1)
        break
      default:
        return
    }
    event.preventDefault()
  }

  return (
    <div className={c.column}>
      <span id={labelId} className={c.label}>
        {label}
      </span>
      <button
        type="button"
        className={c.step}
        aria-label={`Increase ${label.toLowerCase()}`}
        onClick={() => changeBy(1)}
      >
        <span aria-hidden="true">+</span>
      </button>
      <div
        ref={setScene}
        className={c.scene}
        role="spinbutton"
        tabIndex={0}
        aria-labelledby={labelId}
        aria-describedby={hintId}
        aria-valuemin={0}
        aria-valuemax={count - 1}
        aria-valuenow={selected}
        aria-valuetext={`${formatValue(selected)} ${label.toLowerCase()}`}
        onKeyDown={handleKey}
      >
        <div ref={emblaRef} className={c.viewport}>
          <div className={c.container} aria-hidden="true">
            {Array.from({ length: count }, (_, value) => (
              <div key={value} className={c.slide} data-selected={value === selected}>
                {formatValue(value)}
              </div>
            ))}
          </div>
        </div>
        <span className={c.selection} aria-hidden="true" />
      </div>
      <button
        type="button"
        className={c.step}
        aria-label={`Decrease ${label.toLowerCase()}`}
        onClick={() => changeBy(-1)}
      >
        <span aria-hidden="true">−</span>
      </button>
      <span id={hintId} className={c.srOnly}>
        Scroll or drag to select. Up and Down change by one, Page Up and Page Down by five. Home selects the first
        value, End the last. Values loop.
      </span>
    </div>
  )
}

export default function TimePicker() {
  const [hours, setHours] = useState(9)
  const [minutes, setMinutes] = useState(41)

  return (
    <div className={c.demo}>
      <div className={c.picker} role="group" aria-label="Choose a time">
        <PickerColumn label="Hours" count={24} initialValue={9} onChange={setHours} />
        <span className={c.separator} aria-hidden="true">
          :
        </span>
        <PickerColumn label="Minutes" count={60} initialValue={41} onChange={setMinutes} />
      </div>
      <p className={c.result}>
        Selected time{' '}
        <output aria-live="off">
          {formatValue(hours)}:{formatValue(minutes)}
        </output>
      </p>
      <p className={c.hint}>Scroll or drag either column. Use ↑ / ↓ when a column is focused.</p>
    </div>
  )
}

TimePicker.module.scss

.demo {
  --picker-background: #fff;
  padding: 2rem 1rem;
  border: 1px solid rgba(128, 128, 128, 0.2);
  border-radius: 1.5rem;
  background: var(--picker-background);
  text-align: center;

  @media (prefers-color-scheme: dark) {
    --picker-background: #111;
  }
}

.picker {
  display: flex;
  align-items: center;
  justify-content: center;
  max-width: 24rem;
  margin: auto;
}

.column {
  flex: 1;
  min-width: 0;
}

.label {
  display: block;
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin-bottom: 0.5rem;
}

.step {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  font-size: 1.25rem;
  color: inherit;

  &:hover {
    background: rgba(128, 128, 128, 0.1);
  }
}

.scene {
  position: relative;
  display: flex;
  align-items: center;
  height: 260px;
  overflow: hidden;
  border-radius: 0.75rem;
  touch-action: pan-x pinch-zoom;
  cursor: grab;
  user-select: none;

  &:active {
    cursor: grabbing;
  }

  &::before,
  &::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    height: calc(50% - 20px);
    z-index: 2;
    pointer-events: none;
  }

  &::before {
    top: 0;
    background: linear-gradient(to bottom, var(--picker-background), transparent);
  }

  &::after {
    bottom: 0;
    background: linear-gradient(to top, var(--picker-background), transparent);
  }
}

.scene:focus-visible,
.step:focus-visible {
  outline: 2px solid #ff4949;
  outline-offset: 3px;
}

.viewport {
  width: 100%;
  height: 40px;
  perspective: 1000px;
  touch-action: pan-x pinch-zoom;
}

.container {
  height: 100%;
  transform-style: preserve-3d;
}

.slide {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.75rem;
  font-variant-numeric: tabular-nums;
  backface-visibility: hidden;
  opacity: 0;

  &[data-selected='true'] {
    color: #d52e2e;
    font-weight: 600;
  }

  @media (prefers-color-scheme: dark) {
    &[data-selected='true'] {
      color: #ff7777;
    }
  }
}

.selection {
  position: absolute;
  inset: calc(50% - 20px) 0 auto;
  height: 40px;
  border-block: 1px solid rgba(128, 128, 128, 0.3);
  background: rgba(128, 128, 128, 0.05);
  pointer-events: none;
}

.separator {
  width: 2rem;
  padding-top: 1.5rem;
  font-size: 1.5rem;
}

.result {
  margin-top: 1.5rem;
  font-size: 0.9rem;

  output {
    margin-left: 0.75rem;
    font-size: 1.5rem;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
  }
}

.hint {
  margin-top: 0.75rem;
  font-size: 0.8rem;
  opacity: 0.8;
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Inspired by Embla’s iOS picker, by David Jerleke (MIT). The wheel presentation uses Embla v8 internals; keep these versions together.

See momentum detection

Scroll inside the panel, then lift your fingers from the trackpad. The solid box follows active input; the translucent box also follows the momentum that continues after release. A mouse wheel may produce little or no momentum.

  • Solid: user input
  • Translucent: user input + momentum

Bars show each wheel event’s movement. Faded bars indicate detected momentum; the plot resets for each gesture.

Input: Idle
View code: See momentum detection

This uses the core library directly. It works without React or Embla.

npm install wheel-gestures
import { WheelGestures } from 'wheel-gestures'

// Attach only to your interactive panel, so the rest of the page still scrolls.
const panel = document.querySelector('.gesture-panel')
const gestures = WheelGestures({ preventWheelAction: true })

if (panel) {
  const unobserve = gestures.observe(panel)
  const unsubscribe = gestures.on('wheel', ({ isMomentum, isEnding, axisDelta }) => {
    const phase = isEnding ? 'idle' : isMomentum ? 'momentum' : 'user input'
    console.log(phase, axisDelta)
    // Update your interface here.
  })

  // On teardown (for example, in your framework's effect cleanup):
  // unsubscribe()
  // unobserve()
}

See the event reference for movement, velocity, and gesture lifecycle fields.