Quick Select

A dropdown for two or three choices makes you open a list, read it and aim at a row — three steps to flip something you already understood.

SelectionProductivityFilesEditorintermediate
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/quick-select.json

The problem

A dropdown for two or three choices makes you open a list, read it, and aim at a row — three steps to flip something you already understood before you pressed anything. The list adds nothing when the whole option set would fit on one line.

Laying the options out as a row above the trigger makes the set visible at a glance and every option a single tap, while the trigger keeps showing the current value rather than being replaced by the menu.

Use cases

  • Visibility, permission or scope on a share sheet
  • A two- or three-way mode switch that changes what a Save button does
  • Any binary that is worth confirming visually before committing

Not for: more than about four options — the row grows wider than the trigger it hangs off and the arrow stops pointing at anything. Use a real listbox. Not for options that need descriptions — use create-menu. Not for a choice that is already visible and permanent: if the row can just live on the page, use expanding-segments and skip the popover.

Installation

bash
npm install motion lucide-react

Then copy QuickSelect.tsx. It depends only on @/lib/cn.

Usage

tsx
import QuickSelect from "@/patterns/quick-select/QuickSelect";
import { Globe, Lock } from "lucide-react";

<QuickSelect
  label="Visibility"
  defaultValue="public"
  onValueChange={(id) => setVisibility(id)}
  options={[
    { id: "private", label: "Private", icon: <Lock /> },
    { id: "public", label: "Public", icon: <Globe /> },
  ]}
/>;

The popover opens upward, so give the container headroom — the component does not reserve it, because how much depends on where you anchor it.

API

PropTypeDefaultDescription
optionsreadonly QuickSelectOption[]—Two to four. Empty renders nothing.
labelstring—Names the control, e.g. "Visibility".
defaultValuestringfirst optionUncontrolled initial selection.
valuestringuncontrolledControlled selection. Pass with onValueChange.
onValueChange(id: string) => void—Fires on selection.
classNamestring—Merged onto the root.

QuickSelectOption: { id: string; label: string; icon: ReactNode }.

Keyboard

KeyAction
Enter / SpaceOpen the popover, or commit the focused option
← → ↑ ↓Move between options, wrapping
Home / EndFirst or last option
EscapeClose without changing the value, focus back on the trigger

Accessibility

It could not be closed. The popover only toggled from the trigger, so clicking anywhere else left it hanging over the page — the dismissal everyone reaches for first did nothing. It closes on outside pointerdown and on Escape now.

The trigger was a button with no aria-haspopup, no aria-expanded and no indication it owned a popup, and the options were plain buttons with no selected state — a screen reader user heard "Private" and "Public" with nothing saying either was chosen. It is an APG listbox now: the trigger declares the popup and its state, the popover is a labelled role="listbox", and each option carries aria-selected.

The trigger's accessible name is "Visibility: Public" rather than just "Public". The value is the button's visible text, so without the prefix it announces the answer without the question.

Opening focuses the current value, so the first arrow press moves from where you are rather than from the top of the row. Closing returns focus to the trigger. The focus effect deliberately depends on open alone — keying it to the selection as well would drag focus around while you are still arrowing through the options.

Under prefers-reduced-motion the popover cross-fades with no rise or scale, the value swap loses its vertical travel, and the chevron does not spin. The control still opens, selects and closes.

Performance

transform and opacity only. The trigger carries layout, so the width change when a longer label replaces a shorter one is a projection rather than a reflow.

The value swap sits inside a min-w-[80px] box. Without a floor, exchanging "Public" for "Private" resizes the trigger mid-swap and shoves the chevron sideways while it is spinning — two things moving for one decision.

A permanent will-change: transform on the wrapper is gone. It held a compositor layer for the life of the page to buy a 200ms popover.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

QuickSelect.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";

import { cn } from "@/lib/cn";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface QuickSelectOption {
  id: string;
  label: string;
  icon: React.ReactNode;
}

export interface QuickSelectProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onChange" | "defaultValue"
> {
  /** Two to four. Past that the row is wider than the trigger it hangs off. */
  options: readonly QuickSelectOption[];
  /** Names the control for assistive technology, e.g. "Visibility". */
  label: string;
  defaultValue?: string;
  value?: string;
  onValueChange?: (id: string) => void;
}

/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original                                    */
/* -------------------------------------------------------------------------- */

const popover = {
  hidden: { opacity: 0, y: 10, scale: 0.95 },
  visible: { opacity: 1, y: 0, scale: 1 },
} as const;

const reducedPopover = {
  hidden: { opacity: 0 },
  visible: { opacity: 1 },
} as const;

/** The value swap is faster than the popover: it is a correction, not an entrance. */
const SWAP = { duration: 0.15 } as const;
const POPOVER = { duration: 0.2, ease: "easeOut" } as const;
const CHEVRON = { duration: 0.3, ease: "circOut" } as const;

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A select for a handful of options that shows them all at once.
 *
 * A native select for two or three choices makes you open a list, read it, and
 * aim at a row — three steps to flip something you already understood. Laying
 * the options out as a row above the trigger makes the whole set visible and
 * every one of them a single tap, while the trigger keeps showing the value.
 */
export function QuickSelect({
  options,
  label,
  defaultValue,
  value: controlledValue,
  onValueChange,
  className,
  ...rest
}: QuickSelectProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const listId = `${uid}-list`;

  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const interacted = useRef(false);

  const [open, setOpen] = useState(false);
  const [uncontrolled, setUncontrolled] = useState(
    defaultValue ?? options[0]?.id,
  );
  const selected = controlledValue ?? uncontrolled;
  const selectedIndex = Math.max(
    0,
    options.findIndex((option) => option.id === selected),
  );
  const [activeIndex, setActiveIndex] = useState(selectedIndex);

  const toggle = useCallback((next: boolean) => {
    interacted.current = true;
    setOpen(next);
  }, []);

  const select = useCallback(
    (id: string) => {
      if (controlledValue === undefined) setUncontrolled(id);
      onValueChange?.(id);
      toggle(false);
    },
    [controlledValue, onValueChange, toggle],
  );

  /*
   * Opening puts focus on the current value, so the first arrow press moves
   * from where you are rather than from the top of the list. Closing returns
   * it to the trigger, which is the element that will still be there.
   */
  useEffect(() => {
    if (!interacted.current) return;
    if (open) optionRefs.current[selectedIndex]?.focus();
    else triggerRef.current?.focus();
    // Only on open/close: re-running when the selection changes would drag
    // focus around while the user is arrowing through the options.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  /*
   * The original had no way out but the trigger. Clicking anywhere else left
   * the popover hanging over the page — the one dismissal everybody reaches
   * for first.
   */
  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 move = useCallback((index: number) => {
    setActiveIndex(index);
    optionRefs.current[index]?.focus();
  }, []);

  /** APG listbox: arrows move focus, Enter or Space commits, Escape backs out. */
  const handleOptionKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
      const count = options.length;
      switch (event.key) {
        case "ArrowRight":
        case "ArrowDown":
          event.preventDefault();
          move((index + 1) % count);
          return;
        case "ArrowLeft":
        case "ArrowUp":
          event.preventDefault();
          move((index - 1 + count) % count);
          return;
        case "Home":
          event.preventDefault();
          move(0);
          return;
        case "End":
          event.preventDefault();
          move(count - 1);
          return;
        case "Escape":
          event.preventDefault();
          toggle(false);
          return;
        default:
          return;
      }
    },
    [move, options.length, toggle],
  );

  if (options.length === 0) return null;

  const current = options[selectedIndex];

  return (
    <div
      className={cn(
        "flex w-full min-w-0 items-center justify-center",
        className,
      )}
      {...rest}
    >
      <div ref={rootRef} className="relative inline-block">
        <AnimatePresence>
          {open && (
            <motion.div
              id={listId}
              role="listbox"
              aria-label={label}
              variants={reduce ? reducedPopover : popover}
              initial="hidden"
              animate="visible"
              exit="hidden"
              transition={reduce ? { duration: 0.01 } : POPOVER}
              className="absolute bottom-full left-1/2 z-10 mb-3 -translate-x-1/2"
            >
              <div className="relative flex items-center gap-1 rounded-full border border-border bg-surface-subtle p-1.5">
                {options.map((option, index) => {
                  const isSelected = option.id === selected;
                  return (
                    <button
                      key={option.id}
                      ref={(node) => {
                        optionRefs.current[index] = node;
                      }}
                      type="button"
                      role="option"
                      aria-selected={isSelected}
                      tabIndex={index === activeIndex ? 0 : -1}
                      onClick={() => select(option.id)}
                      onKeyDown={(event) => handleOptionKeyDown(event, index)}
                      className={cn(
                        "focus-ring flex items-center gap-2 bg-surface px-3 py-2",
                        "font-semibold whitespace-nowrap transition-colors duration-200",
                        // Only the ends are rounded, so the row reads as one
                        // control rather than as separate buttons.
                        index === 0 && "rounded-l-full",
                        index === options.length - 1 && "rounded-r-full",
                        isSelected
                          ? "text-content"
                          : "text-content-muted hover:text-content",
                      )}
                    >
                      <span
                        aria-hidden="true"
                        className={cn(
                          "[&>svg]:size-[18px]",
                          isSelected
                            ? "[&>svg]:stroke-[2.5]"
                            : "[&>svg]:stroke-2",
                        )}
                      >
                        {option.icon}
                      </span>
                      {option.label}
                    </button>
                  );
                })}

                <div className="absolute -bottom-1.5 left-1/2 size-3 -translate-x-1/2 rotate-45 bg-surface-subtle" />
              </div>
            </motion.div>
          )}
        </AnimatePresence>

        <motion.button
          ref={triggerRef}
          type="button"
          layout
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={open ? listId : undefined}
          // The value is the button's text, so the name has to say what the
          // value is *of* or it announces as just "Public".
          aria-label={`${label}: ${current.label}`}
          onClick={() => toggle(!open)}
          whileTap={reduce ? undefined : { scale: 0.87 }}
          className={cn(
            "focus-ring group relative flex items-center gap-2 rounded-full",
            "bg-surface-subtle px-4 py-3 text-content-muted",
          )}
        >
          {/*
            A floor width, so swapping "Public" for "Private" does not resize
            the trigger and shove the chevron sideways mid-animation.
          */}
          <span className="flex min-w-[80px] items-center justify-center gap-2">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={current.id}
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5 }}
                animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -5 }}
                transition={reduce ? { duration: 0.01 } : SWAP}
                className="flex items-center gap-2"
              >
                <span
                  aria-hidden="true"
                  className="text-content-subtle [&>svg]:size-[18px] [&>svg]:stroke-[2.5]"
                >
                  {current.icon}
                </span>
                <span className="font-semibold text-content">
                  {current.label}
                </span>
              </motion.span>
            </AnimatePresence>
          </span>

          <motion.span
            aria-hidden="true"
            animate={{ rotate: open && !reduce ? 180 : 0 }}
            transition={reduce ? { duration: 0.01 } : CHEVRON}
            className="grid place-items-center"
          >
            <ChevronDown className="size-4 text-content-subtle" />
          </motion.span>
        </motion.button>
      </div>
    </div>
  );
}

export default QuickSelect;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react)
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original design by nitishkmrk. Renamed from Picker Interaction, which named the widget category rather than the job. Rebuilt with a props API, an APG listbox contract, outside-click and Escape dismissal, focus hand-off in both directions and reduced-motion handling. The popover's rise, the 150ms value swap and the chevron's circOut spin are unchanged.