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.
Selected time
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.5Save these two files together, then render <TimePicker />. CSS Modules with Sass support are required.
npm install --save-dev sassTimePicker.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.
Photo carousel
Swipe horizontally on a trackpad, drag a photo, or use the buttons. Embla handles sliding and snapping; the wheel-gestures plugin adds wheel input.
Built with Embla Carousel and embla-carousel-wheel-gestures.
View code: Photo carousel
Use this minimal carousel in a React project. Replace the numbered slides with your own content.
npm install embla-carousel@8.6.0 embla-carousel-react@8.6.0 embla-carousel-wheel-gestures@8.0.5import useEmblaCarousel from 'embla-carousel-react'
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'
export function Carousel() {
const [emblaRef, emblaApi] = useEmblaCarousel(
{ loop: false, skipSnaps: true },
[WheelGesturesPlugin()]
)
return (
<section aria-label="Example carousel">
<div ref={emblaRef} style={{ overflow: 'hidden' }}>
<div style={{ display: 'flex', touchAction: 'pan-y pinch-zoom' }}>
{[1, 2, 3].map((slide) => (
<div key={slide} style={{ flex: '0 0 100%', minWidth: 0 }}>
Slide {slide}
</div>
))}
</div>
</div>
<button type="button" onClick={() => emblaApi?.scrollPrev()}>Previous</button>
<button type="button" onClick={() => emblaApi?.scrollNext()}>Next</button>
</section>
)
}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.
View code: See momentum detection
This uses the core library directly. It works without React or Embla.
npm install wheel-gesturesimport { 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.



