Web InnoventixFreeCode

Select Menu

Original · free

custom single select dropdown

byWeb InnoventixReact + Tailwind
menuselectmenus
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/menu-select.json
menu-select.tsx
"use client"

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

type Region = {
  value: string
  city: string
  country: string
  flag: string
  latency: number
}

const REGIONS: Region[] = [
  { value: "ap-south-1", city: "Mumbai", country: "India", flag: "🇮🇳", latency: 12 },
  { value: "eu-central-1", city: "Frankfurt", country: "Germany", flag: "🇩🇪", latency: 18 },
  { value: "eu-west-2", city: "London", country: "United Kingdom", flag: "🇬🇧", latency: 22 },
  { value: "us-east-1", city: "N. Virginia", country: "United States", flag: "🇺🇸", latency: 24 },
  { value: "ap-southeast-1", city: "Singapore", country: "Singapore", flag: "🇸🇬", latency: 27 },
  { value: "ap-northeast-1", city: "Tokyo", country: "Japan", flag: "🇯🇵", latency: 33 },
  { value: "us-west-2", city: "Oregon", country: "United States", flag: "🇺🇸", latency: 41 },
  { value: "sa-east-1", city: "São Paulo", country: "Brazil", flag: "🇧🇷", latency: 58 },
]

function latencyTone(ms: number): string {
  if (ms <= 20) return "text-emerald-600 dark:text-emerald-400"
  if (ms <= 35) return "text-amber-600 dark:text-amber-400"
  return "text-rose-600 dark:text-rose-400"
}

function ChevronIcon({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 20 20"
      aria-hidden="true"
      className={
        "h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200 dark:text-slate-500 " +
        (open ? "-rotate-180" : "rotate-0")
      }
    >
      <path
        d="M5 7.5 10 12.5 15 7.5"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  )
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" aria-hidden="true" className={className}>
      <path
        d="M4.5 10.5 8 14l7.5-8"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  )
}

function GlobeIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.5" />
      <path
        d="M3 12h18M12 3c2.5 2.6 2.5 15.4 0 18M12 3c-2.5 2.6-2.5 15.4 0 18"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
      />
    </svg>
  )
}

export default function MenuSelect() {
  const reduceMotion = useReducedMotion()

  const [open, setOpen] = useState<boolean>(false)
  const [selectedValue, setSelectedValue] = useState<string>("")
  const [activeIndex, setActiveIndex] = useState<number>(-1)

  const rootRef = useRef<HTMLDivElement | null>(null)
  const buttonRef = useRef<HTMLButtonElement | null>(null)
  const listRef = useRef<HTMLUListElement | null>(null)
  const optionRefs = useRef<Array<HTMLLIElement | null>>([])
  const searchRef = useRef<string>("")
  const searchTimer = useRef<number | null>(null)

  const rawId = useId()
  const baseId = "menusel-" + rawId.replace(/[^a-zA-Z0-9_-]/g, "")
  const labelId = `${baseId}-label`
  const listId = `${baseId}-list`
  const helpId = `${baseId}-help`
  const optionId = useCallback((i: number) => `${baseId}-opt-${i}`, [baseId])

  const selectedIndex = useMemo(
    () => REGIONS.findIndex((r) => r.value === selectedValue),
    [selectedValue],
  )
  const selected = selectedIndex >= 0 ? REGIONS[selectedIndex] : null
  const recommendedValue = REGIONS[0].value

  const openMenu = useCallback(
    (startIndex?: number) => {
      setOpen(true)
      setActiveIndex(
        typeof startIndex === "number"
          ? startIndex
          : selectedIndex >= 0
            ? selectedIndex
            : 0,
      )
    },
    [selectedIndex],
  )

  const closeMenu = useCallback((refocus: boolean) => {
    setOpen(false)
    setActiveIndex(-1)
    if (refocus) buttonRef.current?.focus()
  }, [])

  const commit = useCallback(
    (index: number) => {
      const region = REGIONS[index]
      if (!region) return
      setSelectedValue(region.value)
      closeMenu(true)
    },
    [closeMenu],
  )

  // Close on outside pointer press.
  useEffect(() => {
    if (!open) return
    function onPointerDown(event: PointerEvent) {
      if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
        closeMenu(false)
      }
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [open, closeMenu])

  // Keep the active option scrolled into view.
  useEffect(() => {
    if (!open || activeIndex < 0) return
    optionRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" })
  }, [open, activeIndex])

  // Clear typeahead timer on unmount.
  useEffect(() => {
    return () => {
      if (searchTimer.current !== null) window.clearTimeout(searchTimer.current)
    }
  }, [])

  const runTypeahead = useCallback(
    (char: string) => {
      searchRef.current += char.toLowerCase()
      if (searchTimer.current !== null) window.clearTimeout(searchTimer.current)
      searchTimer.current = window.setTimeout(() => {
        searchRef.current = ""
      }, 550)

      const query = searchRef.current
      const match = REGIONS.findIndex((r) =>
        (r.city + " " + r.country).toLowerCase().startsWith(query),
      )
      if (match >= 0) {
        if (!open) openMenu(match)
        else setActiveIndex(match)
      }
    },
    [open, openMenu],
  )

  const onKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>) => {
      const key = event.key

      if (!open) {
        if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === " ") {
          event.preventDefault()
          openMenu(key === "ArrowUp" ? REGIONS.length - 1 : undefined)
          return
        }
        if (key === "Home") {
          event.preventDefault()
          openMenu(0)
          return
        }
        if (key === "End") {
          event.preventDefault()
          openMenu(REGIONS.length - 1)
          return
        }
        if (key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
          event.preventDefault()
          runTypeahead(key)
        }
        return
      }

      switch (key) {
        case "ArrowDown":
          event.preventDefault()
          setActiveIndex((i) => (i + 1) % REGIONS.length)
          break
        case "ArrowUp":
          event.preventDefault()
          setActiveIndex((i) => (i - 1 + REGIONS.length) % REGIONS.length)
          break
        case "Home":
          event.preventDefault()
          setActiveIndex(0)
          break
        case "End":
          event.preventDefault()
          setActiveIndex(REGIONS.length - 1)
          break
        case "Enter":
        case " ":
          event.preventDefault()
          if (activeIndex >= 0) commit(activeIndex)
          break
        case "Escape":
          event.preventDefault()
          closeMenu(true)
          break
        case "Tab":
          if (activeIndex >= 0) commit(activeIndex)
          break
        default:
          if (key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
            event.preventDefault()
            runTypeahead(key)
          }
      }
    },
    [open, activeIndex, openMenu, commit, closeMenu, runTypeahead],
  )

  const panelTransition = reduceMotion
    ? { duration: 0 }
    : { duration: 0.18, ease: [0.16, 1, 0.3, 1] as const }

  return (
    <section className="relative w-full bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes menusel-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.55; transform: scale(0.82); }
        }
        .menusel-live-dot {
          animation: menusel-pulse 2.2s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .menusel-live-dot { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-md">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="menusel-live-dot inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Live latency from your network
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
            Deploy your cluster
          </h2>
          <p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
            Pick the region closest to your users. You can migrate later without downtime.
          </p>
        </header>

        <div ref={rootRef} className="relative">
          <label
            id={labelId}
            className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300"
          >
            Deployment region
          </label>

          <button
            ref={buttonRef}
            type="button"
            role="combobox"
            aria-haspopup="listbox"
            aria-expanded={open}
            aria-controls={open ? listId : undefined}
            aria-labelledby={labelId}
            aria-describedby={helpId}
            aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
            onClick={() => (open ? closeMenu(false) : openMenu())}
            onKeyDown={onKeyDown}
            className={
              "flex w-full items-center gap-3 rounded-xl border bg-white px-3.5 py-3 text-left shadow-sm outline-none transition-colors " +
              "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 " +
              "dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 " +
              (open
                ? "border-indigo-500 dark:border-indigo-400"
                : "border-slate-300 hover:border-slate-400 dark:border-slate-700 dark:hover:border-slate-600")
            }
          >
            <span
              className={
                "grid h-8 w-8 shrink-0 place-items-center rounded-lg " +
                (selected
                  ? "bg-indigo-50 dark:bg-indigo-500/15"
                  : "bg-slate-100 dark:bg-slate-800")
              }
            >
              {selected ? (
                <span className="text-lg leading-none" aria-hidden="true">
                  {selected.flag}
                </span>
              ) : (
                <GlobeIcon className="h-5 w-5 text-slate-400 dark:text-slate-500" />
              )}
            </span>

            <span className="min-w-0 flex-1">
              {selected ? (
                <>
                  <span className="block truncate text-sm font-medium text-slate-900 dark:text-white">
                    {selected.city}
                    <span className="ml-1.5 font-normal text-slate-400 dark:text-slate-500">
                      {selected.value}
                    </span>
                  </span>
                  <span className={"block text-xs font-medium " + latencyTone(selected.latency)}>
                    {selected.latency} ms round-trip
                  </span>
                </>
              ) : (
                <span className="block truncate text-sm text-slate-400 dark:text-slate-500">
                  Select a region…
                </span>
              )}
            </span>

            <ChevronIcon open={open} />
          </button>

          <p id={helpId} className="mt-2 text-xs text-slate-400 dark:text-slate-500">
            Use arrow keys to browse, type to jump, Enter to select.
          </p>

          <AnimatePresence>
            {open && (
              <motion.div
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.985 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.985 }}
                transition={panelTransition}
                style={{ transformOrigin: "top center" }}
                className="absolute left-0 right-0 z-20 mt-2"
              >
                <ul
                  ref={listRef}
                  id={listId}
                  role="listbox"
                  aria-labelledby={labelId}
                  tabIndex={-1}
                  className="max-h-72 overflow-y-auto overscroll-contain rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
                >
                  {REGIONS.map((region, index) => {
                    const isSelected = region.value === selectedValue
                    const isActive = index === activeIndex
                    const isRecommended = region.value === recommendedValue
                    return (
                      <li
                        key={region.value}
                        ref={(el) => {
                          optionRefs.current[index] = el
                        }}
                        id={optionId(index)}
                        role="option"
                        aria-selected={isSelected}
                        onClick={() => commit(index)}
                        onMouseEnter={() => setActiveIndex(index)}
                        className={
                          "flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 " +
                          (isActive
                            ? "bg-indigo-50 dark:bg-indigo-500/15"
                            : "bg-transparent")
                        }
                      >
                        <span className="text-lg leading-none" aria-hidden="true">
                          {region.flag}
                        </span>

                        <span className="min-w-0 flex-1">
                          <span className="flex items-center gap-2">
                            <span
                              className={
                                "truncate text-sm " +
                                (isSelected
                                  ? "font-semibold text-slate-900 dark:text-white"
                                  : "font-medium text-slate-800 dark:text-slate-100")
                              }
                            >
                              {region.city}
                            </span>
                            {isRecommended && (
                              <span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400">
                                Recommended
                              </span>
                            )}
                          </span>
                          <span className="block truncate text-xs text-slate-400 dark:text-slate-500">
                            {region.country} · {region.value}
                          </span>
                        </span>

                        <span
                          className={"shrink-0 text-xs font-medium tabular-nums " + latencyTone(region.latency)}
                        >
                          {region.latency} ms
                        </span>

                        <span className="grid w-4 shrink-0 place-items-center">
                          {isSelected && (
                            <CheckIcon className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
                          )}
                        </span>
                      </li>
                    )
                  })}
                </ul>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <div className="mt-6 flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm dark:border-slate-800 dark:bg-slate-900">
          <span className="text-slate-500 dark:text-slate-400">Current selection</span>
          <span className="font-medium text-slate-900 dark:text-white">
            {selected ? `${selected.city} (${selected.value})` : "None yet"}
          </span>
        </div>
      </div>
    </section>
  )
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →