Skip to content

Animated Select

A filterable list that scales from its trigger.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/animated-select.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion radix-ui tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/animated-select/animated-select.tsx
    "use client";
    
    import { useId, useMemo, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react";
    import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { Popover } from "radix-ui";
    import { ChevronsUpDown, Search } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease, overlayVariants, spring } from "@/lib/motion";
    
    export interface SelectOption {
      /** Value reported when the option is chosen. */
      value: string;
      /** Visible label; also what the filter matches. */
      label: string;
      /** Secondary text under the label. */
      description?: string;
      /** Icon shown before the label. */
      icon?: ReactNode;
      /** Prevents choosing the option. */
      disabled?: boolean;
    }
    
    export interface AnimatedSelectProps
      extends Omit<ComponentProps<"button">, "value" | "defaultValue" | "onChange" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** The options, in order. */
      options: SelectOption[];
      /** Controlled selected value. */
      value?: string;
      /** Initially selected value when uncontrolled. */
      defaultValue?: string;
      /** Called with the newly selected value. */
      onValueChange?: (value: string) => void;
      /** Trigger text when nothing is selected. @default "Select…" */
      placeholder?: string;
      /** Show a filter field at the top of the list. @default false */
      searchable?: boolean;
      /** Text shown when the filter matches nothing. @default "No results." */
      emptyText?: string;
      /** Submits the selected value under this name. */
      name?: string;
    }
    
    export function AnimatedSelect({
      options,
      value,
      defaultValue,
      onValueChange,
      placeholder = "Select…",
      searchable = false,
      emptyText = "No results.",
      name,
      disabled,
      className,
      ...props
    }: AnimatedSelectProps) {
      const [internal, setInternal] = useState(defaultValue);
      const selectedValue = value ?? internal;
      const selected = options.find((o) => o.value === selectedValue);
      const [open, setOpen] = useState(false);
      const [query, setQuery] = useState("");
      const [active, setActive] = useState(0);
      const listRef = useRef<HTMLDivElement>(null);
      const searchRef = useRef<HTMLInputElement>(null);
      const typeahead = useRef({ text: "", timer: 0 });
      const reduceMotion = useReducedMotion();
      const listId = useId();
      const groupId = useId();
    
      const filtered = useMemo(() => {
        const q = query.trim().toLowerCase();
        return q ? options.filter((o) => o.label.toLowerCase().includes(q) || o.description?.toLowerCase().includes(q)) : options;
      }, [options, query]);
    
      function openChange(next: boolean) {
        setOpen(next);
        if (next) {
          setQuery("");
          setActive(Math.max(0, options.findIndex((o) => o.value === selectedValue)));
        }
      }
    
      function choose(option: SelectOption | undefined) {
        if (!option || option.disabled) return;
        if (value === undefined) setInternal(option.value);
        onValueChange?.(option.value);
        setOpen(false);
      }
    
      /** Moves the highlight, skipping disabled options in direction `dir`. */
      function move(to: number, dir: 1 | -1) {
        if (!filtered.length) return;
        const count = filtered.length;
        let index = (to + count) % count;
        for (let i = 0; i < count && filtered[index]?.disabled; i++) index = (index + dir + count) % count;
        setActive(index);
        document.getElementById(`${listId}-${index}`)?.scrollIntoView({ block: "nearest" });
      }
    
      function onListKey(event: KeyboardEvent<HTMLElement>) {
        const keys: Record<string, () => void> = {
          ArrowDown: () => move(active + 1, 1),
          ArrowUp: () => move(active - 1, -1),
          Home: () => move(0, 1),
          End: () => move(filtered.length - 1, -1),
          Enter: () => choose(filtered[active]),
        };
        if (!searchable) keys[" "] = () => choose(filtered[active]);
        const action = keys[event.key];
        if (action) {
          event.preventDefault();
          action();
          return;
        }
        // Typeahead when there is no filter field: jump to the first label starting with the typed text.
        if (!searchable && event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
          window.clearTimeout(typeahead.current.timer);
          typeahead.current.text += event.key.toLowerCase();
          typeahead.current.timer = window.setTimeout(() => {
            typeahead.current.text = "";
          }, 500);
          const match = filtered.findIndex((o) => !o.disabled && o.label.toLowerCase().startsWith(typeahead.current.text));
          if (match !== -1) move(match, 1);
        }
      }
    
      return (
        <Popover.Root open={open} onOpenChange={openChange}>
          {/* Select-only combobox pattern: screen readers announce the label and the current value. */}
          <Popover.Trigger
            disabled={disabled}
            role="combobox"
            aria-haspopup="listbox"
            aria-controls={open ? listId : undefined}
            className={cn(
              "inline-flex h-10 w-full cursor-pointer items-center gap-2 rounded-md border border-input bg-background px-3 text-left text-sm shadow-xs outline-none",
              "transition-[border-color,box-shadow] duration-150 hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/20",
              "data-[state=open]:border-ring data-[state=open]:ring-3 data-[state=open]:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50",
              "[&_svg]:size-4 [&_svg]:shrink-0",
              className,
            )}
            {...props}
          >
            <span className="relative grid min-w-0 flex-1 *:[grid-area:1/1]">
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span
                  key={selected?.value ?? "placeholder"}
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(3px)" }}
                  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                  exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(3px)" }}
                  transition={{ duration: duration.quick, ease: ease.out }}
                  className={cn("flex min-w-0 items-center gap-2", selected ? "text-foreground" : "text-muted-foreground")}
                >
                  {selected?.icon}
                  <span className="truncate">{selected?.label ?? placeholder}</span>
                </motion.span>
              </AnimatePresence>
            </span>
            <ChevronsUpDown className="text-muted-foreground" aria-hidden="true" />
          </Popover.Trigger>
          {name ? <input type="hidden" name={name} value={selectedValue ?? ""} /> : null}
    
          <AnimatePresence>
            {open ? (
              <Popover.Portal forceMount>
                <Popover.Content
                  forceMount
                  asChild
                  align="start"
                  sideOffset={6}
                  collisionPadding={12}
                  onOpenAutoFocus={(event) => {
                    event.preventDefault();
                    (searchable ? searchRef.current : listRef.current)?.focus();
                  }}
                  onKeyDown={searchable ? undefined : onListKey}
                >
                  <motion.div
                    variants={overlayVariants(reduceMotion)}
                    initial="hidden"
                    animate="visible"
                    exit="exit"
                    // Scale out of the trigger, whichever side Radix places the list on.
                    style={{ transformOrigin: "var(--radix-popover-content-transform-origin)" }}
                    className="z-50 w-[var(--radix-popover-trigger-width)] min-w-48 overflow-hidden rounded-[10px] border border-border bg-popover text-popover-foreground shadow-lg outline-none"
                  >
                    {searchable ? (
                      <div className="flex items-center gap-2 border-b border-border px-3">
                        <Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
                        <input
                          ref={searchRef}
                          value={query}
                          onChange={(event) => {
                            setQuery(event.target.value);
                            setActive(0);
                          }}
                          onKeyDown={onListKey}
                          role="combobox"
                          aria-expanded="true"
                          aria-controls={listId}
                          aria-activedescendant={filtered.length ? `${listId}-${active}` : undefined}
                          aria-label="Filter options"
                          placeholder="Search…"
                          className="h-10 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
                        />
                      </div>
                    ) : null}
                    <LayoutGroup id={groupId}>
                      <div
                        ref={listRef}
                        id={listId}
                        role="listbox"
                        tabIndex={searchable ? undefined : -1}
                        aria-activedescendant={!searchable && filtered.length ? `${listId}-${active}` : undefined}
                        aria-label={props["aria-label"] ?? "Options"}
                        className="max-h-72 overflow-y-auto overscroll-contain p-1 outline-none"
                      >
                        {filtered.length === 0 ? (
                          <p className="px-2.5 py-6 text-center text-[13px] text-muted-foreground">{emptyText}</p>
                        ) : (
                          filtered.map((option, index) => {
                            const highlighted = index === active;
                            const isSelected = option.value === selectedValue;
                            return (
                              <div
                                key={option.value}
                                id={`${listId}-${index}`}
                                role="option"
                                aria-selected={isSelected}
                                aria-disabled={option.disabled || undefined}
                                onPointerMove={() => !highlighted && !option.disabled && setActive(index)}
                                onClick={() => choose(option)}
                                className={cn(
                                  "relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm [&_svg]:size-4 [&_svg]:shrink-0",
                                  option.disabled && "cursor-not-allowed opacity-45",
                                )}
                              >
                                {highlighted && !option.disabled ? (
                                  <motion.span
                                    layoutId="active-row"
                                    aria-hidden="true"
                                    transition={reduceMotion ? { duration: 0 } : spring.snappy}
                                    className="absolute inset-0 rounded-md bg-accent"
                                  />
                                ) : null}
                                {option.icon ? <span className="relative flex text-muted-foreground">{option.icon}</span> : null}
                                <span className="relative min-w-0 flex-1">
                                  <span className="block truncate text-foreground">{option.label}</span>
                                  {option.description ? (
                                    <span className="block truncate text-xs text-muted-foreground">{option.description}</span>
                                  ) : null}
                                </span>
                                <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="relative size-4 text-foreground">
                                  <motion.path
                                    d="M5 12.5l4.5 4.5L19 7.5"
                                    stroke="currentColor"
                                    strokeWidth={2.5}
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                    initial={{ pathLength: isSelected ? 1 : 0 }}
                                    animate={{ pathLength: isSelected ? 1 : 0, opacity: isSelected ? 1 : 0 }}
                                    transition={{ duration: reduceMotion ? 0 : duration.base, ease: ease.out }}
                                  />
                                </svg>
                              </div>
                            );
                          })
                        )}
                      </div>
                    </LayoutGroup>
                  </motion.div>
                </Popover.Content>
              </Popover.Portal>
            ) : null}
          </AnimatePresence>
        </Popover.Root>
      );
    }

Usage

import { AnimatedSelect } from "@/components/ui/animated-select/animated-select";

export function FrameworkField() {
  const [framework, setFramework] = useState("next");
  return (
    <AnimatedSelect
      aria-label="Framework"
      value={framework}
      onValueChange={setFramework}
      options={[
        { value: "next", label: "Next.js" },
        { value: "remix", label: "Remix" },
      ]}
    />
  );
}

API reference

PropTypeDefaultDescription
options*SelectOption[]–The options, in order.
valuestring–Controlled selected value.
defaultValuestring–Initially selected value when uncontrolled.
onValueChange(value: string) => void–Called with the newly selected value.
placeholderstring"Select…"Trigger text when nothing is selected.
searchablebooleanfalseShow a filter field at the top of the list.
emptyTextstring"No results."Text shown when the filter matches nothing.
namestring–Submits the selected value under this name.

Also accepts every native <button> attribute, including ref and className.

  • The list scales from the trigger using Radix's --radix-popover-content-transform-origin, so it grows from the right edge when flipped above or below.
  • The list matches the trigger width, with a 12rem minimum.
  • Pass name to submit the value through a hidden input.

Accessibility

  • The trigger follows the select-only combobox pattern (role="combobox", aria-expanded), so screen readers announce its label and current value.
  • In the list, arrow keys move the highlight, Home and End jump, Enter (or Space without a filter) chooses, and Escape closes; focus returns to the trigger.
  • Without a filter field, typing letters jumps to the first matching option; with searchable, the field filters as you type.
  • Disabled options are announced as unavailable and skipped by the arrow keys.
  • With reduced motion enabled, the list fades instead of scaling and the highlight jumps between rows.