Scroll, use the keyboard or type to jump to an option. Works in React, Vue, Svelte, Solid, Preact and vanilla JS.
- Native scroll snapping, so touch momentum and trackpads feel right
- Keyboard, page keys and typeahead
- Optional looping
- Disabled items, controlled and uncontrolled values
- A hidden input for forms
Installation
in the preview, then copy the prompt into Claude Code, Cursor or another coding agent, or add the component yourself with the shadcn CLI or by hand.
Show prompt
Add the wheel picker from isbatak-labs to this project. It's a scrollable wheel for picking one value from a list, built on native scroll snapping with optional looping. Use the React version styled with Panda CSS and built on the Zag state machine. Docs: https://isbatak-labs.vercel.app/components/prototypes/wheel-picker.md 1. Install it with the shadcn CLI. Swap `pnpm dlx` for `npx`, `yarn dlx` or `bunx` to match the project's lockfile: pnpm dlx shadcn@latest add https://isbatak-labs.vercel.app/r/wheel-picker-basic-react-panda.json This installs `@isbatak/zag-wheel-picker` and `@zag-js/react`, plus `@isbatak/panda-wheel-picker` as a dev dependency, and adds the component to `src/components/wheel-picker-basic/`. If the CLI can't run here, install those packages yourself and write the files listed in https://isbatak-labs.vercel.app/r/wheel-picker-basic-react-panda.json (each entry has its content and target path). 2. Finish the setup: - Panda CSS: add `"@isbatak/panda-wheel-picker"` to the presets in the Panda config (or import `wheelPickerPreset` from it if the config already imports its presets), then run `panda codegen`. If the project doesn't use Panda CSS, stop and tell me to pick the CSS version in the docs instead. 3. Ask me where the picker should go and what it should list, and suggest a spot if one stands out. Then render it there: copy the installed component, give it a name that fits, replace the items and the label with mine, and keep the value options that fit (a default value, or a controlled value with a change handler). 4. Start the dev server, check that the picker renders, scrolls and snaps to a value, then stop it. If you can't run it, tell me how to check it myself.
pnpm dlx shadcn@latest add https://isbatak-labs.vercel.app/r/wheel-picker-basic-react-panda.jsonThis installs the dependencies and adds the component to src/components/wheel-picker-basic/. It imports the recipe from styled-system/recipes.
Add "@isbatak/panda-wheel-picker" to presets in your Panda config, or import wheelPickerPreset from it and add that instead.
- Install the dependencies:
pnpm add @isbatak/zag-wheel-picker @zag-js/react pnpm add -D @isbatak/panda-wheel-picker Add
"@isbatak/panda-wheel-picker"topresetsin your Panda config, or importwheelPickerPresetfrom it and add that instead.- Copy these files into
src/components/wheel-picker-basic/:"use client" import { wheelPicker as wheelPickerRecipe } from "styled-system/recipes" import * as wheelPicker from "@isbatak/zag-wheel-picker" import { normalizeProps } from "@zag-js/react" import { useEffect, useId, useLayoutEffect, useRef, useState } from "react" const items = [ { label: "React", value: "react" }, { label: "Vue", value: "vue" }, { label: "Angular", value: "angular", disabled: true }, { label: "Svelte", value: "svelte" }, { label: "Solid", value: "solid" }, { label: "Preact", value: "preact" }, { label: "Qwik", value: "qwik" }, { label: "Lit", value: "lit" }, ] const styles = wheelPickerRecipe() export function WheelPickerBasic() { const id = useId() const [value, setValue] = useState("react") const [picker] = useState(() => wheelPicker.createWheelPicker()) const props: wheelPicker.Props = { id, items, value, onValueChange: (details) => setValue(details.value), } const latestProps = useRef(props) latestProps.current = props useEffect(() => picker.mount(() => latestProps.current), [picker]) useLayoutEffect(() => picker.sync(latestProps.current), [picker, value]) const api = picker.connect(props, normalizeProps) return ( <div {...api.getRootProps()} className={styles.root}> <span {...api.getLabelProps()} className={styles.label}> Framework </span> <div {...api.getControlProps()} className={styles.control}> <div {...api.getViewportProps()} className={styles.viewport}> <div {...api.getItemGroupProps()} className={styles.itemGroup}> {api.rows.map(({ row, item }) => ( <div key={row} {...api.getItemProps({ row })} className={styles.item}> {item.label} </div> ))} </div> </div> <div {...api.getHighlightProps()} className={styles.highlight} /> </div> <input {...api.getHiddenInputProps()} /> </div> ) }
Examples
Scroll to an example to load it in the preview, and open the code view there for the full source. The settings button in the preview changes the example live, and the snippet under it follows. Each example is its own registry item, so you can add it with the CLI the same way.
Controlled value
Pass value and onValueChange to own the state, or bind the value with v-model in Vue and bind:value in Svelte.
Anything that updates the value, like the buttons under the wheel, moves the picker to that item.
const picker = wheelPicker.createWheelPicker()
const api = picker.connect(
{
id,
items,
value,
onValueChange: (details) => setValue(details.value),
},
normalizeProps,
)Looping
Set loop to repeat the items, so the wheel wraps from the last item back to the first. The items render several times
and the wheel jumps back to the middle copy whenever scrolling stops, so it never reaches an end.
const picker = wheelPicker.createWheelPicker()
const api = picker.connect(
{
id,
items,
value: "30",
onValueChange: (details) => setValue(details.value),
loop: true,
},
normalizeProps,
)Styling
The viewport is a scroll container with scroll-snap-type: y mandatory, padded so the first and last rows can reach the
middle. The highlight sits behind the rows and the Panda recipe fades the viewport edges with a mask. Where the browser
supports scroll-driven animations, each row tilts away from the middle like a drum. The row height and the number of
rows in view come from the itemHeight and visibleCount props, so size the text to fit them.
Accessibility
The viewport is a spinbutton that announces the selected label through aria-valuetext. The rows are hidden from
assistive tech, since the viewport already reports the value. Arrow keys move one row, Page Up and Page Down move a
page, Home and End jump to the ends, and typing jumps to the next label that matches. Disabled rows are skipped, and a
flick that lands on one moves to the nearest enabled row. Give each wheel a name with aria-label or the label part.
Props
| Prop | Type | Default |
|---|---|---|
id | string The unique id of the wheel picker. | — |
ids | ElementIds The ids of the elements in the wheel picker. Useful for composition. | — |
items | WheelPickerItem[] The items to pick from. | — |
value | string The selected value. | — |
onValueChange | (details: ValueChangeDetails) => void Function called when the selected value changes. | — |
loop | boolean Whether the items repeat, so the wheel never reaches an end. | false |
itemHeight | number The height of each row in pixels. | 36 |
visibleCount | number The number of rows in view. Keep it odd so one row sits in the middle. | 5 |
disabled | boolean Whether the wheel picker is disabled. | — |
name | string The name of the hidden input, for native forms. | — |
form | string The id of the form that owns the hidden input. | — |
aria-label | string The accessible label for the wheel picker. | — |
aria-labelledby | string The id of the element that labels the wheel picker. | — |
API
| Property | Type |
|---|---|
value | string The selected value. |
selectedItem | WheelPickerItem The selected item. |
rows | WheelPickerRow[] The rows to render. A looping wheel renders its items several times. |