Dropdown
Toggleable menu for actions and links.
Code
components/Dropdown.astro
---interface DropdownItem { label: string; href?: string; onClick?: string;} interface Props { items: DropdownItem[]; trigger?: "click" | "hover"; label?: string; class?: string;} const { items, trigger = "click", label = "Dropdown", class: className = "",} = Astro.props; const menuId = `dropdown-menu-${Math.random().toString(36).slice(2, 9)}`;--- <div class:list={["dropdown relative inline-block", trigger === "hover" && "dropdown-hover", className]}> <button class="dropdown-trigger flex items-center gap-2 px-4 py-2 text-sm bg-black-onyx text-white-ghost rounded hover:bg-black-onyx/90 transition-colors cursor-pointer" aria-haspopup="true" aria-expanded="false" aria-controls={menuId} > {label} <svg aria-hidden="true" class="dropdown-arrow transition-transform" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 256 256"> <path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80a8,8,0,0,1,11.32-11.32L128,165.05l74.34-74.35a8,8,0,0,1,11.32,11.32Z"></path> </svg> </button> <div id={menuId} role="menu" class="dropdown-menu absolute left-0 top-full mt-1 min-w-40 bg-white-ghost border border-black-onyx/10 rounded shadow-lg shadow-black-onyx/10 opacity-0 invisible translate-y-1 transition-all duration-200 z-50" > <ul class="py-1"> {items.map((item) => ( <li role="none"> {item.href ? ( <a href={item.href} role="menuitem" class="block px-4 py-2 text-sm text-black-onyx/80 hover:bg-black-onyx/5 hover:text-black-onyx transition-colors" > {item.label} </a> ) : ( <button role="menuitem" class="w-full text-left block px-4 py-2 text-sm text-black-onyx/80 hover:bg-black-onyx/5 hover:text-black-onyx transition-colors cursor-pointer" onclick={item.onClick} > {item.label} </button> )} </li> ))} </ul> </div></div> <script> function openMenu(dropdown) { const trigger = dropdown.querySelector(".dropdown-trigger"); const menu = dropdown.querySelector(".dropdown-menu"); const arrow = dropdown.querySelector(".dropdown-arrow"); menu.classList.add("opacity-100", "visible", "translate-y-0"); menu.classList.remove("translate-y-1"); arrow?.classList.add("rotate-180"); trigger?.setAttribute("aria-expanded", "true"); const firstItem = menu.querySelector('[role="menuitem"]'); firstItem?.focus(); } function closeMenu(dropdown) { const trigger = dropdown.querySelector(".dropdown-trigger"); const menu = dropdown.querySelector(".dropdown-menu"); const arrow = dropdown.querySelector(".dropdown-arrow"); menu.classList.remove("opacity-100", "visible", "translate-y-0"); menu.classList.add("translate-y-1"); arrow?.classList.remove("rotate-180"); trigger?.setAttribute("aria-expanded", "false"); } function isOpen(dropdown) { const menu = dropdown.querySelector(".dropdown-menu"); return menu.classList.contains("opacity-100"); } // Click trigger document.querySelectorAll(".dropdown:not(.dropdown-hover)").forEach((dropdown) => { const trigger = dropdown.querySelector(".dropdown-trigger"); const menu = dropdown.querySelector(".dropdown-menu"); trigger?.addEventListener("click", (e) => { e.stopPropagation(); if (isOpen(dropdown)) { closeMenu(dropdown); } else { // Close other open dropdowns document.querySelectorAll(".dropdown:not(.dropdown-hover)").forEach((d) => { if (d !== dropdown) closeMenu(d); }); openMenu(dropdown); } }); document.addEventListener("click", () => { closeMenu(dropdown); }); // Keyboard nav within menu dropdown.addEventListener("keydown", (e) => { if (!isOpen(dropdown)) return; const items = menu.querySelectorAll('[role="menuitem"]'); const current = Array.from(items).indexOf(document.activeElement); if (e.key === "Escape") { e.preventDefault(); closeMenu(dropdown); trigger?.focus(); } else if (e.key === "ArrowDown") { e.preventDefault(); const next = (current + 1) % items.length; items[next]?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); const prev = (current - 1 + items.length) % items.length; items[prev]?.focus(); } else if (e.key === "Home") { e.preventDefault(); items[0]?.focus(); } else if (e.key === "End") { e.preventDefault(); items[items.length - 1]?.focus(); } }); }); // Hover trigger — add focus/keyboard support document.querySelectorAll(".dropdown-hover").forEach((dropdown) => { const menu = dropdown.querySelector(".dropdown-menu"); const arrow = dropdown.querySelector(".dropdown-arrow"); let timeout; function show() { clearTimeout(timeout); menu.classList.add("opacity-100", "visible", "translate-y-0"); menu.classList.remove("translate-y-1"); arrow?.classList.add("rotate-180"); dropdown.querySelector(".dropdown-trigger")?.setAttribute("aria-expanded", "true"); } function hide() { timeout = setTimeout(() => { menu.classList.remove("opacity-100", "visible", "translate-y-0"); menu.classList.add("translate-y-1"); arrow?.classList.remove("rotate-180"); dropdown.querySelector(".dropdown-trigger")?.setAttribute("aria-expanded", "false"); }, 150); } dropdown.addEventListener("mouseenter", show); dropdown.addEventListener("mouseleave", hide); // Focus-in / focus-out for keyboard users dropdown.querySelector(".dropdown-trigger")?.addEventListener("focus", show); dropdown.querySelector(".dropdown-trigger")?.addEventListener("blur", (e) => { if (!dropdown.contains(e.relatedTarget)) hide(); }); menu.addEventListener("focusin", show); menu.addEventListener("focusout", (e) => { if (!dropdown.contains(e.relatedTarget)) hide(); }); dropdown.addEventListener("keydown", (e) => { if (!isOpen(dropdown)) return; const items = menu.querySelectorAll('[role="menuitem"]'); const current = Array.from(items).indexOf(document.activeElement); if (e.key === "Escape") { e.preventDefault(); hide(); dropdown.querySelector(".dropdown-trigger")?.focus(); } else if (e.key === "ArrowDown") { e.preventDefault(); const next = (current + 1) % items.length; items[next]?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); const prev = (current - 1 + items.length) % items.length; items[prev]?.focus(); } }); });</script>
Preview
Click Trigger
Hover Trigger
Usage
<Dropdown trigger="click" label="Menu" items={[ { label: "Profile", href: "#" }, { label: "Settings", href: "#" }, { label: "Logout", href: "#" }, ]}/>
<Dropdown trigger="hover" label="Hover" items={[ { label: "Dashboard", href: "#" }, { label: "Projects", href: "#" }, { label: "Team", href: "#" }, ]}/>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | DropdownItem[] | — | Array of menu items with label and optional href or onClick. |
| trigger | "click" | "hover" | "click" | How the dropdown opens. |
| label | string | "Dropdown" | Trigger button label. |
| class | string | — | Additional CSS classes. |