Multiselect Menu
Original · freemulti-select with checkable items
byWeb InnoventixReact + Tailwind
menumultiselectmenus
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-multiselect.jsonmenu-multiselect.tsx
"use client"
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
type DataSource = {
id: string
name: string
category: string
detail: string
}
const SOURCES: DataSource[] = [
{ id: "ga4", name: "Google Analytics 4", category: "Marketing", detail: "Web & app traffic, events, conversions" },
{ id: "stripe", name: "Stripe", category: "Payments", detail: "Charges, subscriptions, live MRR" },
{ id: "shopify", name: "Shopify", category: "Commerce", detail: "Orders, products, customer records" },
{ id: "hubspot", name: "HubSpot", category: "CRM", detail: "Contacts, deals, email engagement" },
{ id: "salesforce", name: "Salesforce", category: "CRM", detail: "Accounts, opportunities, pipeline" },
{ id: "snowflake", name: "Snowflake", category: "Warehouse", detail: "Query your modeled tables directly" },
{ id: "postgres", name: "PostgreSQL", category: "Database", detail: "Production app DB, read replica" },
{ id: "segment", name: "Segment", category: "Pipeline", detail: "Unified customer event stream" },
{ id: "mixpanel", name: "Mixpanel", category: "Product", detail: "Funnels, retention, cohort splits" },
{ id: "zendesk", name: "Zendesk", category: "Support", detail: "Tickets, CSAT, response times" },
{ id: "intercom", name: "Intercom", category: "Support", detail: "Conversations, resolution rate" },
{ id: "amplitude", name: "Amplitude", category: "Product", detail: "Behavioral analytics, feature usage" },
]
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path
d="M4.5 10.5l3.2 3.2 7.8-8"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M6 6l8 8M14 6l-8 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)
}
function PlugIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M9 3v4M15 3v4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path
d="M7 7h10v3a5 5 0 0 1-5 5 5 5 0 0 1-5-5V7Z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M12 15v6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<circle cx="9" cy="9" r="5.2" stroke="currentColor" strokeWidth="1.8" />
<path d="M13 13l4 4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)
}
export default function MenuMultiselect() {
const reduce = useReducedMotion()
const rootRef = useRef<HTMLDivElement | null>(null)
const buttonRef = useRef<HTMLButtonElement | null>(null)
const searchRef = useRef<HTMLInputElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const uid = useId()
const menuId = `${uid}-menu`
const searchId = `${uid}-search`
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [selected, setSelected] = useState<string[]>(["ga4", "stripe", "postgres"])
const [activeIndex, setActiveIndex] = useState(0)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return SOURCES
return SOURCES.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.category.toLowerCase().includes(q) ||
s.detail.toLowerCase().includes(q),
)
}, [query])
const count = selected.length
const allVisibleSelected = filtered.length > 0 && filtered.every((s) => selected.includes(s.id))
useEffect(() => {
setActiveIndex((a) => Math.min(a, Math.max(0, filtered.length - 1)))
}, [filtered.length])
useEffect(() => {
if (!open) return
const t = window.setTimeout(() => searchRef.current?.focus(), 20)
return () => window.clearTimeout(t)
}, [open])
useEffect(() => {
if (!open) return
function onPointerDown(e: PointerEvent) {
const target = e.target as Node
if (rootRef.current && !rootRef.current.contains(target)) setOpen(false)
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [open])
function closeMenu(focusButton: boolean) {
setOpen(false)
setQuery("")
if (focusButton) window.setTimeout(() => buttonRef.current?.focus(), 0)
}
function focusItem(index: number) {
if (filtered.length === 0) return
const clamped = Math.max(0, Math.min(index, filtered.length - 1))
setActiveIndex(clamped)
itemRefs.current[clamped]?.focus()
}
function toggle(id: string) {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
}
function selectAllVisible() {
setSelected((prev) => {
const merged = new Set(prev)
for (const s of filtered) merged.add(s.id)
return Array.from(merged)
})
}
function clearAll() {
setSelected([])
}
function onButtonKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
setOpen(true)
}
}
function onSearchKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault()
focusItem(0)
} else if (e.key === "Escape") {
e.preventDefault()
if (query) setQuery("")
else closeMenu(true)
} else if (e.key === "Enter") {
e.preventDefault()
const first = filtered[0]
if (first) toggle(first.id)
}
}
function onItemKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>, index: number) {
switch (e.key) {
case "ArrowDown":
e.preventDefault()
focusItem(index + 1 >= filtered.length ? 0 : index + 1)
break
case "ArrowUp":
e.preventDefault()
if (index === 0) searchRef.current?.focus()
else focusItem(index - 1)
break
case "Home":
e.preventDefault()
focusItem(0)
break
case "End":
e.preventDefault()
focusItem(filtered.length - 1)
break
case "Enter":
case " ": {
e.preventDefault()
const item = filtered[index]
if (item) toggle(item.id)
break
}
case "Escape":
e.preventDefault()
closeMenu(true)
break
case "Tab":
closeMenu(false)
break
default:
break
}
}
const selectedSources = SOURCES.filter((s) => selected.includes(s.id))
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes msq-check {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes msq-chip {
0% { transform: translateY(3px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@keyframes msq-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.7); }
}
.msq-check { animation: msq-check 220ms cubic-bezier(0.16,1,0.3,1) both; }
.msq-chip { animation: msq-chip 200ms ease-out both; }
.msq-pulse { animation: msq-pulse 2.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.msq-check, .msq-chip, .msq-pulse { animation: none !important; }
}
`}</style>
<div className="pointer-events-none absolute inset-x-0 top-0 mx-auto h-64 max-w-3xl bg-gradient-to-b from-indigo-200/40 to-transparent blur-2xl dark:from-indigo-500/10" />
<div className="relative mx-auto max-w-xl">
<div 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="msq-pulse inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
Workspace setup
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight sm:text-3xl">Connect your data sources</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Choose which platforms Northwind syncs into your analytics workspace. You can change this selection
anytime from settings.
</p>
</div>
<div ref={rootRef} className="relative">
<span className="sr-only" aria-live="polite">
{count === 0 ? "No sources selected" : `${count} source${count === 1 ? "" : "s"} selected`}
</span>
<button
ref={buttonRef}
type="button"
onClick={() => setOpen((o) => !o)}
onKeyDown={onButtonKeyDown}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
className="flex w-full items-center gap-3 rounded-2xl border border-slate-300 bg-white px-4 py-3.5 text-left shadow-sm transition-colors hover:border-slate-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600 dark:focus-visible:ring-offset-slate-950"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
<PlugIcon className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{count === 0 ? "Select data sources" : `${count} source${count === 1 ? "" : "s"} connected`}
</span>
<span className="block truncate text-xs text-slate-500 dark:text-slate-400">
{count === 0
? "Nothing syncing yet — pick at least one"
: selectedSources.map((s) => s.name).join(", ")}
</span>
</span>
{count > 0 && (
<span className="shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
{count}
</span>
)}
<motion.span
className="shrink-0 text-slate-400"
animate={reduce ? undefined : { rotate: open ? 180 : 0 }}
transition={{ duration: 0.18 }}
>
<ChevronIcon className="h-5 w-5" />
</motion.span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={reduce ? false : { opacity: 0, y: -6, scale: 0.98 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
className="absolute left-0 right-0 top-full z-20 mt-2 origin-top overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40"
>
<div className="border-b border-slate-100 p-3 dark:border-slate-800">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">
<SearchIcon className="h-4 w-4" />
</span>
<label htmlFor={searchId} className="sr-only">
Filter data sources
</label>
<input
ref={searchRef}
id={searchId}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onSearchKeyDown}
placeholder="Search integrations…"
autoComplete="off"
className="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-slate-400 focus:border-indigo-400 focus:bg-white focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500 dark:focus:border-indigo-500 dark:focus:bg-slate-800"
/>
</div>
<div className="mt-2 flex items-center justify-between px-0.5">
<span className="text-xs text-slate-500 dark:text-slate-400">
{filtered.length} available · {count} selected
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={selectAllVisible}
disabled={allVisibleSelected}
className="rounded-md px-2 py-1 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-300 dark:hover:bg-indigo-500/10 dark:disabled:text-slate-600"
>
Select all
</button>
<button
type="button"
onClick={clearAll}
disabled={count === 0}
className="rounded-md px-2 py-1 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-300 disabled:hover:bg-transparent focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-300 dark:hover:bg-slate-800 dark:disabled:text-slate-600"
>
Clear
</button>
</div>
</div>
</div>
<div
id={menuId}
role="menu"
aria-label="Data sources"
className="max-h-72 overflow-y-auto p-1.5"
>
{filtered.length === 0 ? (
<p className="px-3 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
No integrations match “{query}”.
</p>
) : (
filtered.map((source, index) => {
const isSelected = selected.includes(source.id)
return (
<button
key={source.id}
ref={(el) => {
itemRefs.current[index] = el
}}
type="button"
role="menuitemcheckbox"
aria-checked={isSelected}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => toggle(source.id)}
onKeyDown={(e) => onItemKeyDown(e, index)}
onMouseEnter={() => setActiveIndex(index)}
className="group flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-inset dark:hover:bg-slate-800"
>
<span
className={
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors " +
(isSelected
? "border-emerald-500 bg-emerald-500 text-white"
: "border-slate-300 bg-white text-transparent group-hover:border-slate-400 dark:border-slate-600 dark:bg-slate-900")
}
>
{isSelected && <CheckIcon className="msq-check h-3.5 w-3.5" />}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{source.name}
</span>
<span className="shrink-0 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{source.category}
</span>
</span>
<span className="mt-0.5 block truncate text-xs text-slate-500 dark:text-slate-400">
{source.detail}
</span>
</span>
</button>
)
})
)}
</div>
<div className="flex items-center justify-between border-t border-slate-100 px-3 py-2.5 dark:border-slate-800">
<span className="text-xs text-slate-500 dark:text-slate-400">
Arrow keys to navigate · Space to toggle
</span>
<button
type="button"
onClick={() => closeMenu(true)}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
>
Done
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{selectedSources.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Connected
</p>
<ul className="flex flex-wrap gap-2">
{selectedSources.map((source) => (
<li key={source.id}>
<span className="msq-chip inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white py-1 pl-2.5 pr-1 text-xs font-medium text-slate-700 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{source.name}
<button
type="button"
onClick={() => toggle(source.id)}
aria-label={`Remove ${source.name}`}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 dark:hover:bg-rose-500/15"
>
<CloseIcon className="h-3 w-3" />
</button>
</span>
</li>
))}
</ul>
</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

Select Menu
Originalcustom single select dropdown

Command Menu
Originalcommand palette style menu with search

Actions Menu
Originalactions/kebab menu

Share Menu
Originalshare menu with options

Notifications Menu
Originalnotifications dropdown list

