# Select Animated Dropdown — React Select Component

> A custom select whose menu springs open with staggered rows and a highlight that glides between options; arrow keys, Home/End, Enter, Escape and typeahead all work.

- Category: [Selects](https://pixelpapa.com/components/selects.md)
- Tags: select, dropdown, listbox, keyboard, typeahead
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/selects/select-animated-dropdown

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/select-animated-dropdown.json"
```

The file is written to `components/pixelpapa/select-animated-dropdown.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Check, ChevronsUpDown, Rocket } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";

export type SelectOption = { value: string; label: string; hint?: string; icon?: ReactNode };

type SelectProps = {
  options: SelectOption[];
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  placeholder?: string;
  label: string;
  className?: string;
};

export function Select({ options, value, defaultValue, onChange, placeholder = "Select…", label, className }: SelectProps) {
  const [inner, setInner] = useState(defaultValue);
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const typed = useRef({ text: "", at: 0 });
  const root = useRef<HTMLDivElement>(null);
  const uid = useId();
  const current = value ?? inner;
  const selectedIndex = options.findIndex((o) => o.value === current);
  const selected = options[selectedIndex];
  const last = options.length - 1;

  useEffect(() => {
    if (open) document.getElementById(`${uid}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
  }, [open, active, uid]);

  useEffect(() => {
    if (!open) return;
    const close = (e: PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", close);
    return () => document.removeEventListener("pointerdown", close);
  }, [open]);

  function show() {
    setActive(Math.max(0, selectedIndex));
    setOpen(true);
  }

  function choose(i: number) {
    const next = options[i].value;
    if (value === undefined) setInner(next);
    onChange?.(next);
    setOpen(false);
  }

  // Focus never leaves the trigger; the active row is exposed through aria-activedescendant.
  function onKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
    if (e.key === "Tab") return setOpen(false);
    if (e.key.length === 1 && e.key !== " ") {
      const now = e.timeStamp;
      typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + e.key.toLowerCase(), at: now };
      const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text));
      if (hit >= 0) (open ? setActive : choose)(hit);
      return;
    }
    if (!open) {
      if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
        e.preventDefault();
        show();
      }
      return;
    }
    if (e.key === "ArrowDown") setActive((a) => Math.min(last, a + 1));
    else if (e.key === "ArrowUp") setActive((a) => Math.max(0, a - 1));
    else if (e.key === "Home") setActive(0);
    else if (e.key === "End") setActive(last);
    else if (e.key === "Enter" || e.key === " ") choose(active);
    else if (e.key === "Escape") setOpen(false);
    else return;
    e.preventDefault();
  }

  return (
    <div ref={root} className={cn("relative w-full", className)}>
      <button
        type="button"
        role="combobox"
        aria-label={label}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={`${uid}-list`}
        aria-activedescendant={open ? `${uid}-opt-${active}` : undefined}
        onClick={() => (open ? setOpen(false) : show())}
        onKeyDown={onKeyDown}
        onBlur={() => setOpen(false)}
        className={cn(
          "flex h-11 w-full items-center gap-2.5 rounded-xl border bg-background px-3.5 text-left text-sm shadow-xs transition-[border-color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15",
          open ? "border-ring ring-4 ring-ring/15" : "border-input",
        )}
      >
        <span className="relative flex h-5 min-w-0 flex-1 overflow-hidden">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={selected?.value ?? "placeholder"}
              initial={{ y: 14, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: -14, opacity: 0 }}
              transition={{ type: "spring", stiffness: 420, damping: 32 }}
              className={cn("flex min-w-0 items-center gap-2.5 leading-5", selected ? "text-foreground" : "text-muted-foreground")}
            >
              {selected?.icon && <span className="shrink-0 text-muted-foreground [&>svg]:size-4">{selected.icon}</span>}
              <span className="truncate">{selected?.label ?? placeholder}</span>
            </motion.span>
          </AnimatePresence>
        </span>
        <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
      </button>

      <AnimatePresence>
        {open && (
          <motion.ul
            id={`${uid}-list`}
            role="listbox"
            aria-label={label}
            initial="closed"
            animate="open"
            exit="closed"
            variants={{
              closed: { opacity: 0, y: -8, scale: 0.96, transition: { duration: 0.12 } },
              open: {
                opacity: 1,
                y: 0,
                scale: 1,
                transition: { type: "spring", stiffness: 460, damping: 30, staggerChildren: 0.025 },
              },
            }}
            onPointerDown={(e) => e.preventDefault()}
            className="absolute top-full left-0 z-20 mt-2 max-h-64 w-full origin-top overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-xl shadow-black/10"
          >
            {options.map((o, i) => (
              <motion.li
                key={o.value}
                id={`${uid}-opt-${i}`}
                role="option"
                aria-selected={i === selectedIndex}
                variants={{ closed: { opacity: 0, x: -6 }, open: { opacity: 1, x: 0 } }}
                onPointerMove={() => i !== active && setActive(i)}
                onClick={() => choose(i)}
                className="relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm text-popover-foreground"
              >
                {i === active && (
                  <motion.span
                    layoutId={`${uid}-active`}
                    transition={{ type: "spring", stiffness: 620, damping: 42 }}
                    className="absolute inset-0 rounded-lg bg-accent"
                  />
                )}
                {o.icon && <span className="relative shrink-0 text-muted-foreground [&>svg]:size-4">{o.icon}</span>}
                <span className="relative flex-1 truncate">{o.label}</span>
                {o.hint && <span className="relative text-xs text-muted-foreground tabular-nums">{o.hint}</span>}
                <span className="relative flex size-4 items-center justify-center">
                  {i === selectedIndex && (
                    <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 22 }}>
                      <Check className="size-4 text-primary" strokeWidth={2.5} />
                    </motion.span>
                  )}
                </span>
              </motion.li>
            ))}
          </motion.ul>
        )}
      </AnimatePresence>
    </div>
  );
}

const dot = (color: string) => <span className={cn("block size-2 rounded-full", color)} />;

const regions: SelectOption[] = [
  { value: "fra", label: "Frankfurt", hint: "12 ms", icon: dot("bg-emerald-500") },
  { value: "ams", label: "Amsterdam", hint: "18 ms", icon: dot("bg-emerald-500") },
  { value: "lon", label: "London", hint: "24 ms", icon: dot("bg-emerald-500") },
  { value: "nyc", label: "New York", hint: "88 ms", icon: dot("bg-amber-500") },
  { value: "sfo", label: "San Francisco", hint: "146 ms", icon: dot("bg-amber-500") },
  { value: "sgp", label: "Singapore", hint: "171 ms", icon: dot("bg-red-500") },
];

export default function SelectAnimatedDropdownDemo() {
  const [region, setRegion] = useState("fra");

  return (
    <div className="flex h-[400px] w-full max-w-sm flex-col">
      <div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
        <h3 className="text-lg font-semibold tracking-tight text-card-foreground">New deployment</h3>
        <p className="mt-1 mb-5 text-sm text-muted-foreground">Pick the region closest to your users.</p>
        <p className="mb-1.5 text-sm font-medium text-card-foreground">Region</p>
        <Select label="Region" options={regions} value={region} onChange={setRegion} />
        <button
          type="button"
          className="mt-4 flex h-10 w-full items-center justify-center gap-2 rounded-xl bg-primary text-sm font-medium text-primary-foreground"
        >
          <Rocket className="size-4" /> Deploy to {regions.find((r) => r.value === region)?.label}
        </button>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Select Animated Dropdown" component from PixelPapa UI to this project.

What it is: A custom select whose menu springs open with staggered rows and a highlight that glides between options; arrow keys, Home/End, Enter, Escape and typeahead all work.
Category: Selects. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/select-animated-dropdown.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/select-animated-dropdown.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/select-animated-dropdown" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More selects

- [Select Avatar People](https://pixelpapa.com/components/selects/select-avatar-people.md) (Pro): An assignee picker with gradient initials avatars and presence dots; the chosen avatar pops into the trigger and the list filters from a built-in search.
- [Select Cascading Menu](https://pixelpapa.com/components/selects/select-cascading-menu.md) (Pro): A two-level select: pick a group on the left and its children slide in on the right; arrow keys move within a column and Left/Right step between levels.
- [Select Color Swatches](https://pixelpapa.com/components/selects/select-color-swatches.md): A radio group of colour swatches where a ring springs from one swatch to the next and the colour name rolls over; arrow keys move the selection.
- [Select Combobox Search](https://pixelpapa.com/components/selects/select-combobox-search.md) (Pro): A searchable combobox that filters as you type, bolds the matching letters, slides a highlight under the active row and restores the selection on Escape.
- [Select Multi Chips](https://pixelpapa.com/components/selects/select-multi-chips.md): A multi-select whose picks become chips that spring into the field and reflow when removed; type to filter, Enter toggles, Backspace removes the last chip.
