Filter Menu
Original · freefilter dropdown with checkboxes
byWeb InnoventixReact + Tailwind
menufiltermenus
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-filter.jsonmenu-filter.tsx
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type FilterOption = { id: string; label: string };
type FilterGroup = { id: string; label: string; options: FilterOption[] };
const GROUPS: FilterGroup[] = [
{
id: "roast",
label: "Roast level",
options: [
{ id: "roast:light", label: "Light" },
{ id: "roast:medium", label: "Medium" },
{ id: "roast:medium-dark", label: "Medium-dark" },
{ id: "roast:dark", label: "Dark" },
],
},
{
id: "origin",
label: "Origin",
options: [
{ id: "origin:ethiopia", label: "Ethiopia" },
{ id: "origin:colombia", label: "Colombia" },
{ id: "origin:guatemala", label: "Guatemala" },
{ id: "origin:kenya", label: "Kenya" },
{ id: "origin:brazil", label: "Brazil" },
{ id: "origin:sumatra", label: "Sumatra" },
],
},
{
id: "process",
label: "Process",
options: [
{ id: "process:washed", label: "Washed" },
{ id: "process:natural", label: "Natural" },
{ id: "process:honey", label: "Honey" },
{ id: "process:anaerobic", label: "Anaerobic" },
],
},
{
id: "notes",
label: "Tasting notes",
options: [
{ id: "notes:fruity", label: "Fruity" },
{ id: "notes:chocolate", label: "Chocolate" },
{ id: "notes:nutty", label: "Nutty" },
{ id: "notes:floral", label: "Floral" },
{ id: "notes:citrus", label: "Citrus" },
{ id: "notes:caramel", label: "Caramel" },
],
},
];
type RawProduct = {
id: string;
name: string;
roaster: string;
price: number;
roast: string;
origin: string;
process: string;
notes: string[];
};
const RAW_PRODUCTS: RawProduct[] = [
{ id: "reko", name: "Yirgacheffe Reko", roaster: "Ridgeline Coffee Roasters", price: 19, roast: "roast:light", origin: "origin:ethiopia", process: "process:washed", notes: ["notes:floral", "notes:citrus", "notes:fruity"] },
{ id: "guji", name: "Guji Natural G1", roaster: "Ridgeline Coffee Roasters", price: 21, roast: "roast:light", origin: "origin:ethiopia", process: "process:natural", notes: ["notes:fruity", "notes:floral"] },
{ id: "huila", name: "Huila Reserva", roaster: "Cormorant Coffee", price: 17, roast: "roast:medium", origin: "origin:colombia", process: "process:washed", notes: ["notes:caramel", "notes:chocolate"] },
{ id: "narino", name: "Nariño Sunrise", roaster: "Cormorant Coffee", price: 18, roast: "roast:medium", origin: "origin:colombia", process: "process:honey", notes: ["notes:caramel", "notes:fruity"] },
{ id: "antigua", name: "Antigua Volcán", roaster: "Foundry Roasters", price: 16, roast: "roast:medium-dark", origin: "origin:guatemala", process: "process:washed", notes: ["notes:chocolate", "notes:nutty"] },
{ id: "nyeri", name: "Nyeri AA Peaberry", roaster: "Foundry Roasters", price: 22, roast: "roast:light", origin: "origin:kenya", process: "process:washed", notes: ["notes:citrus", "notes:fruity"] },
{ id: "cerrado", name: "Cerrado Gold", roaster: "Meridian Coffee Co.", price: 15, roast: "roast:dark", origin: "origin:brazil", process: "process:natural", notes: ["notes:nutty", "notes:chocolate", "notes:caramel"] },
{ id: "mandheling", name: "Mandheling Reserve", roaster: "Meridian Coffee Co.", price: 16, roast: "roast:dark", origin: "origin:sumatra", process: "process:natural", notes: ["notes:chocolate", "notes:nutty"] },
{ id: "aurora", name: "Finca La Aurora", roaster: "Cormorant Coffee", price: 24, roast: "roast:medium", origin: "origin:guatemala", process: "process:anaerobic", notes: ["notes:fruity", "notes:floral", "notes:citrus"] },
];
type Product = RawProduct & { attrs: string[]; set: Set<string> };
const PRODUCTS: Product[] = RAW_PRODUCTS.map((p) => {
const attrs = [p.roast, p.origin, p.process, ...p.notes];
return { ...p, attrs, set: new Set(attrs) };
});
const OPTION_LABEL: Record<string, string> = {};
const OPTION_COUNT: Record<string, number> = {};
for (const group of GROUPS) {
for (const option of group.options) {
OPTION_LABEL[option.id] = option.label;
OPTION_COUNT[option.id] = PRODUCTS.filter((p) => p.set.has(option.id)).length;
}
}
const KEYFRAMES = `
@keyframes mf-badge-pop { 0% { transform: scale(0.5); opacity: 0; } 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } }
@keyframes mf-chip-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
@keyframes mf-card-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.mf-badge { animation: mf-badge-pop 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.mf-chip { animation: mf-chip-in 0.22s ease-out; }
.mf-card { animation: mf-card-in 0.32s cubic-bezier(0.16, 1, 0.3, 1) both; }
@media (prefers-reduced-motion: reduce) {
.mf-badge, .mf-chip, .mf-card { animation: none !important; }
}
`;
function FunnelIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M3 4.5h14L11.5 11v4.5L8.5 17v-6L3 4.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
<path d="M5.5 8l4.5 4.5L14.5 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<circle cx="9" cy="9" r="5.5" stroke="currentColor" strokeWidth="1.6" />
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3 w-3">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
export default function MenuFilter() {
const reduce = useReducedMotion();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [activeItem, setActiveItem] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const clearRef = useRef<HTMLButtonElement>(null);
const applyRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const reactId = useId();
const panelId = `mf-panel-${reactId}`;
const searchId = `mf-search-${reactId}`;
const resultsId = `mf-results-${reactId}`;
const visibleGroups = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return GROUPS;
return GROUPS.map((g) => ({
...g,
options: g.options.filter((o) => o.label.toLowerCase().includes(q)),
})).filter((g) => g.options.length > 0);
}, [query]);
const visibleItemIds = useMemo(
() => visibleGroups.flatMap((g) => g.options.map((o) => o.id)),
[visibleGroups],
);
const selectedIds = useMemo(
() => GROUPS.flatMap((g) => g.options.filter((o) => selected.has(o.id)).map((o) => o.id)),
[selected],
);
const filtered = useMemo(() => {
const selectedByGroup = GROUPS.map((g) => g.options.filter((o) => selected.has(o.id)).map((o) => o.id));
return PRODUCTS.filter((p) =>
selectedByGroup.every((sel) => sel.length === 0 || sel.some((id) => p.set.has(id))),
);
}, [selected]);
const count = selected.size;
const resultCount = filtered.length;
const focusItem = useCallback((id: string | null) => {
if (!id) {
searchRef.current?.focus();
return;
}
setActiveItem(id);
itemRefs.current.get(id)?.focus();
}, []);
const toggle = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearAll = useCallback(() => setSelected(new Set()), []);
const close = useCallback(() => {
setOpen(false);
triggerRef.current?.focus();
}, []);
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => searchRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open]);
useEffect(() => {
if (open && (activeItem === null || !visibleItemIds.includes(activeItem))) {
setActiveItem(visibleItemIds[0] ?? null);
}
}, [open, visibleItemIds, activeItem]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (!target) return;
if (panelRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const getFocusables = useCallback((): HTMLElement[] => {
const panel = panelRef.current;
if (!panel) return [];
const nodes = panel.querySelectorAll<HTMLElement>("button, input, [tabindex]");
return Array.from(nodes).filter(
(el) => el.tabIndex !== -1 && !(el as HTMLButtonElement).disabled && el.offsetParent !== null,
);
}, []);
const onPanelKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
close();
return;
}
if (e.key === "Tab") {
const focusables = getFocusables();
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
},
[close, getFocusables],
);
const onSearchKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
focusItem(visibleItemIds[0] ?? null);
}
},
[focusItem, visibleItemIds],
);
const onItemKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLButtonElement>, id: string) => {
const idx = visibleItemIds.indexOf(id);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (idx < visibleItemIds.length - 1) focusItem(visibleItemIds[idx + 1]);
break;
case "ArrowUp":
e.preventDefault();
if (idx > 0) focusItem(visibleItemIds[idx - 1]);
else focusItem(null);
break;
case "Home":
e.preventDefault();
focusItem(visibleItemIds[0] ?? null);
break;
case "End":
e.preventDefault();
focusItem(visibleItemIds[visibleItemIds.length - 1] ?? null);
break;
case " ":
case "Enter":
e.preventDefault();
toggle(id);
break;
}
},
[focusItem, toggle, visibleItemIds],
);
return (
<section className="relative w-full overflow-hidden bg-neutral-50 px-4 py-16 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100 sm:py-24">
<style>{KEYFRAMES}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[38rem] max-w-[90vw] -translate-x-1/2 rounded-full bg-gradient-to-tr from-indigo-200/50 via-violet-200/40 to-transparent blur-3xl dark:from-indigo-500/15 dark:via-violet-500/10"
/>
<div className="relative mx-auto w-full max-w-5xl">
<header className="mb-8">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
The Roastery
</p>
<h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">Single-origin coffee</h2>
<p className="mt-2 max-w-xl text-sm text-neutral-600 dark:text-neutral-400">
Small-lot beans roasted to order. Narrow the shelf by roast, origin, process, and the flavors you chase in the cup.
</p>
</header>
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={panelId}
onClick={() => (open ? close() : setOpen(true))}
className="inline-flex items-center gap-2 rounded-xl border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-800 shadow-sm transition-colors hover:border-neutral-400 hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:border-neutral-600 dark:hover:bg-neutral-800 dark:focus-visible:ring-offset-neutral-950"
>
<FunnelIcon />
<span>Filters</span>
{count > 0 && (
<span
key={count}
className="mf-badge inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-indigo-600 px-1.5 text-xs font-semibold text-white dark:bg-indigo-500"
>
{count}
</span>
)}
<ChevronIcon
className={`h-4 w-4 text-neutral-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
id={panelId}
role="dialog"
aria-modal="false"
aria-label="Filter coffees"
onKeyDown={onPanelKeyDown}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}
transition={{ duration: reduce ? 0.12 : 0.18, ease: "easeOut" }}
style={{ transformOrigin: "top left" }}
className="absolute left-0 z-30 mt-2 w-[min(92vw,22rem)] overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-2xl shadow-neutral-900/10 dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-black/40"
>
<div className="border-b border-neutral-200 p-2.5 dark:border-neutral-800">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400">
<SearchIcon />
</span>
<input
ref={searchRef}
id={searchId}
type="text"
role="searchbox"
aria-label="Search filters"
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onSearchKeyDown}
placeholder="Search filters..."
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 py-2 pl-9 pr-3 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100 dark:placeholder:text-neutral-500"
/>
</div>
</div>
<div role="menu" aria-label="Filter options" className="max-h-[18rem] overflow-y-auto p-1.5">
{visibleGroups.length === 0 ? (
<p className="px-3 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
No filters match “{query.trim()}”
</p>
) : (
visibleGroups.map((group) => {
const headingId = `mf-grp-${reactId}-${group.id}`;
return (
<div key={group.id} role="group" aria-labelledby={headingId} className="px-1 py-1.5">
<div
id={headingId}
className="px-2 pb-1 text-[0.7rem] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
{group.label}
</div>
{group.options.map((option) => {
const checked = selected.has(option.id);
return (
<button
key={option.id}
ref={(el) => {
if (el) itemRefs.current.set(option.id, el);
else itemRefs.current.delete(option.id);
}}
type="button"
role="menuitemcheckbox"
aria-checked={checked}
tabIndex={activeItem === option.id ? 0 : -1}
onClick={() => toggle(option.id)}
onKeyDown={(e) => onItemKeyDown(e, option.id)}
className="group flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<span
aria-hidden="true"
className={`flex h-[1.15rem] w-[1.15rem] flex-none items-center justify-center rounded-md border transition-colors ${
checked
? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500"
: "border-neutral-300 bg-white text-transparent group-hover:border-neutral-400 dark:border-neutral-600 dark:bg-neutral-900"
}`}
>
{checked && <CheckIcon />}
</span>
<span className="flex-1">{option.label}</span>
<span
aria-hidden="true"
className="text-xs tabular-nums text-neutral-400 dark:text-neutral-500"
>
{OPTION_COUNT[option.id]}
</span>
</button>
);
})}
</div>
);
})
)}
</div>
<div className="flex items-center justify-between gap-2 border-t border-neutral-200 px-3 py-2.5 dark:border-neutral-800">
<button
ref={clearRef}
type="button"
onClick={clearAll}
disabled={count === 0}
className="rounded-lg px-2.5 py-1.5 text-sm font-medium text-neutral-600 transition-colors hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Clear all
</button>
<button
ref={applyRef}
type="button"
onClick={close}
className="rounded-lg bg-indigo-600 px-3.5 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-neutral-900"
>
Show {resultCount} {resultCount === 1 ? "result" : "results"}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{selectedIds.map((id) => (
<button
key={id}
type="button"
onClick={() => toggle(id)}
aria-label={`Remove ${OPTION_LABEL[id]} filter`}
className="mf-chip inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 py-1 pl-3 pr-2 text-sm font-medium text-indigo-700 transition-colors hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300 dark:hover:bg-indigo-500/20 dark:focus-visible:ring-offset-neutral-950"
>
{OPTION_LABEL[id]}
<span className="text-indigo-400 dark:text-indigo-500">
<CloseIcon />
</span>
</button>
))}
{count > 0 && (
<button
type="button"
onClick={clearAll}
className="text-sm font-medium text-neutral-500 underline-offset-4 transition-colors hover:text-neutral-800 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:text-neutral-400 dark:hover:text-neutral-100 dark:focus-visible:ring-offset-neutral-950"
>
Clear all
</button>
)}
</div>
<p id={resultsId} aria-live="polite" className="mt-6 text-sm text-neutral-500 dark:text-neutral-400">
Showing <span className="font-semibold text-neutral-800 dark:text-neutral-100">{resultCount}</span> of{" "}
{PRODUCTS.length} roasts
</p>
{resultCount === 0 ? (
<div className="mt-4 rounded-2xl border border-dashed border-neutral-300 bg-white/50 px-6 py-16 text-center dark:border-neutral-700 dark:bg-neutral-900/40">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-200">No roasts match every filter.</p>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Try removing a tasting note or widening the roast range.
</p>
<button
type="button"
onClick={clearAll}
className="mt-4 inline-flex rounded-lg bg-neutral-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200 dark:focus-visible:ring-offset-neutral-950"
>
Reset filters
</button>
</div>
) : (
<ul className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((p, i) => (
<li
key={p.id}
className="mf-card group rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900"
style={{ animationDelay: reduce ? "0ms" : `${Math.min(i, 8) * 45}ms` }}
>
<div className="flex items-start justify-between gap-3">
<span className="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-400/15 dark:text-amber-300">
{OPTION_LABEL[p.origin]}
</span>
<span className="text-sm font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
${p.price}
</span>
</div>
<h3 className="mt-3 text-base font-semibold tracking-tight text-neutral-900 dark:text-neutral-50">
{p.name}
</h3>
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">{p.roaster}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<span className="inline-flex items-center rounded-md bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{OPTION_LABEL[p.roast]} roast
</span>
<span className="inline-flex items-center rounded-md bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
{OPTION_LABEL[p.process]}
</span>
</div>
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
{p.notes.map((n) => OPTION_LABEL[n]).join(" · ")}
</p>
</li>
))}
</ul>
)}
</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

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

