Select Menu
Original · freecustom single select dropdown
byWeb InnoventixReact + Tailwind
menuselectmenus
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.jsonmenu-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 quoteSimilar components
Browse all →
Dropdown Menu
Originalbasic dropdown menu with items
Dropdown Icons Menu
Originaldropdown with icons and shortcuts

User Menu
Originaluser account dropdown with avatar

Mega Menu
Originalmega menu with grouped links

Mega Cols Menu
Originalmulti-column mega menu with featured

Context Menu
Originalright-click context menu

Nested Menu
Originaldropdown with nested submenus

Multiselect Menu
Originalmulti-select with checkable items

Command Menu
Originalcommand palette style menu with search

Actions Menu
Originalactions/kebab menu

Share Menu
Originalshare menu with options

Notifications Menu
Originalnotifications dropdown list

