Web InnoventixFreeCode

Multiselect Menu

Original · free

multi-select with checkable items

byWeb InnoventixReact + Tailwind
menumultiselectmenus
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-multiselect.json
menu-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 quote

Similar components

Browse all →