Kipachu
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:

  • motion
  • vaul (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;
}