A wheel picker for React.

de·tent /dɪˈtɛnt/ noun

  1. A catch in a mechanism that holds a part in place until it is pushed on: the click that keeps a dial on its number.
  2. The wheel picker for React that finally gets that click right: real inertia, a throw that lands exactly on a row, any content in a row. Vertical or horizontal, accessible, zero dependencies.
It
climbs
rolls
snaps
locks
clicks
settles
slots
falls
counts
into place.
Dynamic sentence: source and setup

Get started

Install it, render it with a list and a label. That's a working, accessible picker. pre-release The API may still change before 1.0.

$npm i detent
Minutes.tsx
'use client'
import { useState } from 'react'
import { Detent } from 'detent'

const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))

export function Minutes() {
  const [value, setValue] = useState('30')
  return <Detent items={minutes} value={value} onChange={setValue} label="Minutes" />
}

What you'd use it for

From a familiar time picker to rich rows and a reaction strip. Each has its source ready to copy.

Pick a time

Hours, minutes and AM/PM: a familiar picker with three looping wheels and one shared indicator.

iOS time picker: source and setup

Pick from a rich list

Logos, prices and sparklines in every row: a row holds whatever markup you give it.

Token list: source and setup

Your own design

The drum, the magnifier and the indicator are props: here a sideways strip of reactions, with a click and a tap per row.

← → · Page Up/Down · Home/End

Reaction strip: source and setup

Styled or headless

<Detent>

Looks finished with no styling. Props for the drum, the indicator, the falloff and the physics. Start here.

Styled
<Detent items={sizes} label="Size" defaultValue="M" />

useDetent

The same motion, input and accessibility; you render the markup. For a design system or anything unusual.

SizePicker.tsx
'use client'
import { useDetent } from 'detent'

const sizes = ['XS', 'S', 'M', 'L', 'XL']

export function SizePicker() {
  const d = useDetent({ items: sizes, label: 'Size', defaultValue: 'M' })
  return (
    <>
      <div {...d.getRootProps({ className: 'my-wheel' })}>
        <div {...d.getTrackProps()}>
          {d.rows.map((r) => (
            <div key={r.key} {...r.getProps({ className: 'my-row' })}>
              {r.item}
            </div>
          ))}
        </div>
      </div>
      {/* Announces the selection to screen readers; it stays visually hidden. */}
      <div {...d.getLiveRegionProps()} />
    </>
  )
}

Built right

7.4 kB
The headless hook, gzipped. The styled component with every preset: 17.8 kB.
No runtime dependencies
Nothing beyond React and React DOM, which you already have.
Keyboard and screen readers
Arrows, Page Up/Down, Home/End and typeahead. A real listbox.
Reduced motion
When asked for less motion, a throw snaps instead of coasting.
Server rendering
The initial selection is rendered on the server, into the HTML.
React 18 and 19
Tested on both, in Chromium and WebKit, and as the packed npm tarball.
Explore the physics and tune the engine

Detent Text

Animate changing labels with a wheel-like roll. Keep matching letters in place, or roll the whole phrase. Native text at rest, interruptible in motion.

Detent Text: source and setup

Detent Number

Prices, counters and percentages that roll by decimal place. Digits stay aligned as signs, separators and columns come and go. Formatted with Intl.NumberFormat.

Detent Number: source and setup

Beyond pickers

Inertia, snapping and a live position work for browsing too.

Custom interactions with the headless hook

Every frame, the hook reports where the wheel is and how fast it moves, through subscribe. These three effects are example implementations built on that: a shader and two canvases, written for this site. They are not part of the package.

A WebGL shader: cards stretch toward the edges, smear with speed and split into color at the outline.