Components
Picker
Scrollable picker drawer with snap selection and a centered indicator.
Overview
Picker renders a bottom drawer with a scrollable, snap-aligned list and a centered indicator. It manages the open state internally, notifies changes as you scroll, and triggers onSubmit when the user confirms the selection.
Installation
Just copy and paste
Dependencies used by the picker:
motionvaul(drawer, shadcn)tailwindcss(classes are hardcoded)
Usage
"use client";
import { useState } from "react";
import { Picker } from "@/components/picker/picker";
import { Button } from "@/components/ui/button";
const VALUES = ["06:00", "07:00", "08:00", "09:00", "10:00", "11:00"];
export function PickerUsage() {
const [value, setValue] = useState(VALUES[2]);
return (
<Picker
initialValue={value}
values={VALUES}
onChange={setValue}
onSubmit={(nextValue) => {
setValue(nextValue);
}}
>
<Button variant="secondary">Pick time ({value})</Button>
</Picker>
);
}Demo
Selected time: 09:00
Source
picker.tsx
import { useRef, useState } from "react";
import { motion } from "motion/react";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "../ui/button";
type TPickerProps = {
initialValue?: string;
onChange?: (value: string) => void;
onSubmit?: (value: string) => void;
onOpenChange?: (open: boolean) => void;
children?: React.ReactElement;
values?: string[];
};
const ITEM_HEIGHT = 40;
export const Picker: React.FC<TPickerProps> = ({
initialValue = "",
onChange,
onSubmit,
onOpenChange,
children,
values = [],
}) => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(initialValue);
const intervalRef = useRef<HTMLDivElement>(null);
const setPaddings = () => {
if (!open) return;
if (intervalRef.current) {
const containerHeight = intervalRef.current.clientHeight || 320;
const padding = (containerHeight - ITEM_HEIGHT + 24) / 2;
intervalRef.current.style.paddingTop = `${padding}px`;
intervalRef.current.style.paddingBottom = `${padding}px`;
}
if (intervalRef.current) {
const index = Math.max(0, values.indexOf(value));
intervalRef.current.scrollTop = index * ITEM_HEIGHT;
}
};
const updateValue = (newValue: string) => {
setValue(newValue);
onChange?.(newValue);
};
const intervalScroll = () => {
if (!intervalRef.current) return;
if (values.length === 0) return;
const top = intervalRef.current.scrollTop;
const rawIndex = Math.round(top / ITEM_HEIGHT);
const clampedIndex = Math.min(values.length - 1, Math.max(0, rawIndex));
const nextValue = values[clampedIndex];
if (nextValue !== value) updateValue(nextValue);
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
onOpenChange?.(isOpen);
};
const handleSubmit = () => {
onSubmit?.(value);
setOpen(false);
onOpenChange?.(false);
};
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent className="h-[50vh] flex-col flex overflow-hidden">
<DrawerTitle className="sr-only">Picker</DrawerTitle>
<motion.div
onViewportEnter={() => setPaddings()}
className="flex justify-center gap-2 flex-col items-center h-full relative"
>
<motion.div
key="day-1"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="absolute inset-4 bottom-[40%] dark:bg-[url(/images/night.png)] bg-[url(/images/day.png)] rounded-2xl -z-2 bg-no-repeat bg-center bg-cover"
></motion.div>
<div className="absolute backdrop-blur-xs inset-0 -z-1"></div>
<motion.div
layoutId={"indicator-interval"}
transition={{
type: "spring",
stiffness: 300,
damping: 40,
}}
className="absolute flex justify-center items-center h-0 right-0 left-0 top-1/2 gap-2 -translate-y-1/2 "
>
<div className="bg-background backdrop-blur-xs -z-1 border-2 h-8 w-[max(var(--cell-max-width),5rem)] rounded-lg"></div>
</motion.div>
<motion.div
className="overflow-y-scroll h-full relative no-scrollbar snap-mandatory snap-y"
ref={intervalRef}
onScroll={intervalScroll}
onViewportEnter={(e) => {
if (e?.target instanceof HTMLElement) {
const maxWidth = getMaxChildWidth(e.target);
e?.target?.parentElement?.style?.setProperty(
"--cell-max-width",
maxWidth + 20 + "px",
);
}
}}
>
{values.map((v) => (
<motion.div
key={v}
className="min-h-10 relative flex items-center justify-center snap-center text-lg"
>
{v}
</motion.div>
))}
</motion.div>
<Button
onClick={handleSubmit}
size="lg"
className="absolute right-4 left-4 bottom-10 h-11 rounded-xl text-lg"
>
Done
</Button>
</motion.div>
</DrawerContent>
</Drawer>
);
};
export function getMaxChildWidth(container: HTMLElement): number {
let max = 0;
const children = Array.from(container.children) as HTMLElement[];
for (const el of children) {
const width = el.getBoundingClientRect().width;
if (width > max) max = width;
}
return max;
}