All patterns

Select Dropdown Open

The list unfolds under the field, options a beat behind the panel, current choice already marked.

formsminimalelegantinteraction · finite · intermediate · ~0.3s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

338 lines · react + motion only
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Select Dropdown Open
 *
 * The list unfolds under the field — panel first, options a beat behind
 * it in a short cascade — with the current choice already marked, so the
 * menu opens onto an answer rather than a question. A real listbox:
 * arrow keys, Home/End, Enter, Escape, and focus returned to the field.
 *
 * Self-contained: depends only on `react` and `motion`. The panel uses
 * the `Canvas` system colors so it stays opaque over whatever it covers,
 * on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `label`, `options`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SelectDropdownOpenProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Field label above the control. */
  label?: string;
  /** Choices in the list. */
  options?: string[];
  /** Index selected on first render. */
  defaultIndex?: number;
  /** Field width. */
  width?: number;
  /** Marker and focus color. */
  accent?: string;
  /** Fires with the chosen index. */
  onSelect?: (index: number) => void;
};

type VariantConfig = {
  /** Carries the panel down from under the field. */
  panel: { type: "spring"; stiffness: number; damping: number };
  /** How far the panel starts above its resting place, in pixels. */
  lift: number;
  /** Seconds between one option arriving and the next. */
  stagger: number;
  /** Seconds for a single option to arrive. */
  option: number;
};

// Quality rule: the panel spring stays at or above a 0.8 damping ratio —
// a menu that bounces under the field makes the whole page feel loose,
// and the option rows would ride the wobble. Nothing here scales: the
// panel and its rows are full of text, so they translate and fade.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a movement, for a form with several of these in a row.
  subtle: {
    panel: { type: "spring", stiffness: 710, damping: 48 },
    lift: 2,
    stagger: 0.012,
    option: 0.1,
  },
  // The panel arrives, the rows follow it down. All-purpose.
  default: {
    panel: { type: "spring", stiffness: 520, damping: 40 },
    lift: 7,
    stagger: 0.022,
    option: 0.16,
  },
  // A longer drop and a visible cascade, for a single prominent picker.
  playful: {
    panel: { type: "spring", stiffness: 350, damping: 31 },
    lift: 12,
    stagger: 0.04,
    option: 0.22,
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or hover fill
 *  that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_OPTIONS = [
  "Every day",
  "Every weekday",
  "Weekly on Monday",
  "Monthly on the 1st",
  "Never",
];

export default function SelectDropdownOpen({
  variant = "default",
  label = "Report frequency",
  options = DEFAULT_OPTIONS,
  defaultIndex = 1,
  width = 260,
  accent = "#5B5BD6",
  onSelect,
}: SelectDropdownOpenProps) {
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState(defaultIndex);
  const [active, setActive] = useState(defaultIndex);
  const [ring, setRing] = useState(false);
  const fieldRef = useRef<HTMLButtonElement>(null);
  const rootRef = useRef<HTMLDivElement>(null);
  const baseId = useId();
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Clicking anywhere else closes the list — a menu that outlives the
  // attention that opened it is a bug, not a feature.
  useEffect(() => {
    if (!open) return;
    const onPointerDown = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  const openList = (startAt: number) => {
    setActive(startAt);
    setOpen(true);
  };

  const commit = (index: number) => {
    setSelected(index);
    setActive(index);
    setOpen(false);
    onSelect?.(index);
    // Focus goes back to the field, so the next Tab continues through
    // the form instead of restarting at the top of the document.
    fieldRef.current?.focus();
  };

  // The full listbox keyboard contract, in one place: open on
  // Enter/Space/arrows, move with arrows and Home/End, commit on
  // Enter/Space, abandon on Escape.
  const onFieldKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    const last = options.length - 1;
    if (event.key === "Escape") {
      if (!open) return;
      event.preventDefault();
      setOpen(false);
    } else if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      if (open) commit(active);
      else openList(selected);
    } else if (event.key === "ArrowDown") {
      event.preventDefault();
      if (open) setActive((current) => (current === last ? 0 : current + 1));
      else openList(selected);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      if (open) setActive((current) => (current === 0 ? last : current - 1));
      else openList(selected);
    } else if (open && event.key === "Home") {
      event.preventDefault();
      setActive(0);
    } else if (open && event.key === "End") {
      event.preventDefault();
      setActive(last);
    }
  };

  return (
    <div ref={rootRef} style={{ position: "relative", width, color: "inherit" }}>
      <div
        id={`${baseId}-label`}
        style={{ fontSize: 12.5, fontWeight: 600, opacity: 0.6, marginBottom: 7 }}
      >
        {label}
      </div>

      <button
        ref={fieldRef}
        type="button"
        role="combobox"
        aria-expanded={open}
        aria-controls={`${baseId}-list`}
        aria-haspopup="listbox"
        aria-labelledby={`${baseId}-label`}
        aria-activedescendant={open ? `${baseId}-option-${active}` : undefined}
        onClick={() => (open ? setOpen(false) : openList(selected))}
        onKeyDown={onFieldKeyDown}
        // The ring is for keyboard users only. `:focus-visible` is the
        // browser's own answer to "was this focus deliberate?" — read it
        // instead of guessing at the input modality.
        onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
        onBlur={() => setRing(false)}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 10,
          width: "100%",
          padding: "10px 12px",
          borderRadius: 10,
          fontSize: 13.5,
          fontFamily: "inherit",
          textAlign: "left",
          color: "inherit",
          background: tone(6),
          border: `1px solid ${open ? accent : tone(14)}`,
          cursor: "pointer",
          outline: "none",
          boxShadow: ring ? `0 0 0 3px ${accent}66` : "none",
          transition: "border-color 160ms ease-out, box-shadow 140ms ease-out",
          WebkitTapHighlightColor: "transparent",
        }}
      >
        <span style={{ flex: 1, minWidth: 0 }}>{options[selected]}</span>

        {/* The chevron turns over rather than swapping glyphs, which is
            the cheapest way to say "this is the same control in another
            state". */}
        <motion.svg
          aria-hidden
          viewBox="0 0 16 16"
          width={15}
          height={15}
          fill="none"
          initial={false}
          animate={{ rotate: open ? 180 : 0 }}
          transition={reduceMotion ? { duration: 0 } : cfg.panel}
          style={{ flexShrink: 0, opacity: 0.6 }}
        >
          <path
            d="M4 6.5 L8 10.5 L12 6.5"
            stroke="currentColor"
            strokeWidth={1.6}
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </motion.svg>
      </button>

      <AnimatePresence>
        {open ? (
          <motion.ul
            id={`${baseId}-list`}
            role="listbox"
            aria-labelledby={`${baseId}-label`}
            initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.lift }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduceMotion ? 0 : -cfg.lift * 0.6 }}
            transition={
              reduceMotion
                ? { duration: 0.1 }
                : { ...cfg.panel, opacity: { duration: cfg.option * 0.8 } }
            }
            style={{
              position: "absolute",
              top: "100%",
              left: 0,
              right: 0,
              zIndex: 20,
              margin: "6px 0 0",
              padding: 5,
              listStyle: "none",
              borderRadius: 12,
              border: `1px solid ${tone(14)}`,
              // The panel sits over page content, so it needs a real
              // opaque background. `Canvas`/`CanvasText` are the CSS
              // system colors for page background and page text: they
              // follow the user's light or dark setting without this file
              // hard-coding either one.
              background: "Canvas",
              color: "CanvasText",
              boxShadow: "0 14px 34px rgba(0,0,0,0.22)",
            }}
          >
            {options.map((option, index) => {
              const isSelected = index === selected;
              const isActive = index === active;
              return (
                <motion.li
                  key={option}
                  id={`${baseId}-option-${index}`}
                  role="option"
                  aria-selected={isSelected}
                  onClick={() => commit(index)}
                  onPointerEnter={() => setActive(index)}
                  initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.lift }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: reduceMotion ? 0 : cfg.option,
                    delay: reduceMotion ? 0 : index * cfg.stagger,
                    ease: "easeOut",
                  }}
                  style={{
                    display: "flex",
                    alignItems: "center",
                    gap: 8,
                    padding: "8px 9px",
                    borderRadius: 8,
                    fontSize: 13.5,
                    cursor: "pointer",
                    // The active row is a background change on a CSS
                    // transition, so moving through the list with the
                    // keyboard costs no layout work.
                    background: isActive ? tone(9) : "transparent",
                    transition: "background-color 120ms ease-out",
                  }}
                >
                  <span style={{ flex: 1, minWidth: 0 }}>{option}</span>

                  {/* The current choice is already marked when the list
                      opens: the menu should answer "what is it now?"
                      before it asks "what should it be?". */}
                  <svg
                    aria-hidden
                    viewBox="0 0 16 16"
                    width={14}
                    height={14}
                    fill="none"
                    style={{
                      flexShrink: 0,
                      opacity: isSelected ? 1 : 0,
                      color: accent,
                      transition: "opacity 140ms ease-out",
                    }}
                  >
                    <path
                      d="M3.4 8.4 L6.4 11.4 L12.6 4.8"
                      stroke="currentColor"
                      strokeWidth={1.9}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                </motion.li>
              );
            })}
          </motion.ul>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

About this pattern

A replacement for the native select that keeps the native keyboard contract. The panel drops from just under the field and the rows follow it in a cascade short enough to feel like one movement, which gives the list a top and a bottom without anything having to scale — everything in a menu is text, and text that scales during an entrance smears. The current value is marked before the list settles, so the menu answers what the setting is now before it asks what it should be. Escape abandons, Enter commits, and focus returns to the field either way.

Settings selectFilter dropdownCountry or currency pickerScheduling form

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Add a supplierTwo fields now, the rest later
    Legal name
    Ridgeline Supply Co.
    Country
    Sweden
    VAT number
    SE556031820101
    Save supplier
    Form

    Opening onto the current value rather than onto the top of the list.

Related patterns