UI Components Medium
Dropdown Menu
Accessible dropdown menu with keyboard navigation, icons, dividers, destructive item variant, and a user account menu demo.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML React Native
Expo Snack
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, sans-serif;
background: #050910;
color: #f2f6ff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.demo {
text-align: center;
}
.demo-title {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.375rem;
}
.demo-sub {
color: #475569;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.triggers {
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
align-items: flex-start;
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.625rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: #94a3b8;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.btn--avatar {
gap: 0.625rem;
}
.avatar-sm {
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(56, 189, 248, 0.18);
color: #38bdf8;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: 700;
}
/* ── Dropdown wrapper ── */
.dropdown {
position: relative;
display: inline-block;
}
/* ── Menu ── */
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: #0d1117;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
padding: 0.375rem;
list-style: none;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
z-index: 200;
opacity: 0;
pointer-events: none;
transform: translateY(6px) scale(0.97);
transform-origin: top left;
transition: opacity 0.15s, transform 0.15s;
}
.dropdown.is-open .dropdown-menu {
opacity: 1;
pointer-events: auto;
transform: none;
}
/* ── Items ── */
.menu-item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: none;
color: #cbd5e1;
font-size: 0.875rem;
border-radius: 0.5rem;
cursor: pointer;
text-align: left;
transition: background 0.1s, color 0.1s;
}
.menu-item:hover,
.menu-item:focus-visible {
background: rgba(255, 255, 255, 0.06);
color: #f2f6ff;
outline: none;
}
.menu-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.menu-item--danger {
color: #f87171;
}
.menu-item--danger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* Checkmark items */
.menu-item--check::before {
content: "";
display: inline-block;
width: 14px;
}
.menu-item--check.is-checked::before {
content: "✓";
color: #38bdf8;
font-size: 0.75rem;
}
.mi-icon {
font-size: 0.875rem;
opacity: 0.7;
}
.menu-divider {
height: 1px;
background: rgba(255, 255, 255, 0.07);
margin: 0.25rem 0;
}
/* Header section */
.menu-header {
padding: 0.5rem 0.875rem;
}
.mh-name {
font-size: 0.8rem;
font-weight: 600;
color: #f2f6ff;
}
.mh-email {
font-size: 0.72rem;
color: #475569;
margin-top: 0.1rem;
}(function () {
var dropdowns = Array.from(document.querySelectorAll(".dropdown"));
function openDropdown(dd) {
dd.classList.add("is-open");
dd.querySelector(".dropdown-trigger").setAttribute("aria-expanded", "true");
var items = dd.querySelectorAll(".menu-item:not([disabled])");
if (items.length) items[0].focus();
}
function closeDropdown(dd) {
dd.classList.remove("is-open");
dd.querySelector(".dropdown-trigger").setAttribute("aria-expanded", "false");
dd.querySelector(".dropdown-trigger").focus();
}
function closeAll() {
dropdowns.forEach(function (d) {
if (d.classList.contains("is-open")) closeDropdown(d);
});
}
dropdowns.forEach(function (dd) {
var trigger = dd.querySelector(".dropdown-trigger");
var menu = dd.querySelector(".dropdown-menu");
trigger.addEventListener("click", function (e) {
e.stopPropagation();
var isOpen = dd.classList.contains("is-open");
closeAll();
if (!isOpen) openDropdown(dd);
});
menu.addEventListener("keydown", function (e) {
var items = Array.from(menu.querySelectorAll(".menu-item:not([disabled])"));
var idx = items.indexOf(document.activeElement);
if (e.key === "ArrowDown") {
e.preventDefault();
items[(idx + 1) % items.length].focus();
}
if (e.key === "ArrowUp") {
e.preventDefault();
items[(idx - 1 + items.length) % items.length].focus();
}
if (e.key === "Escape") {
closeDropdown(dd);
}
});
// Select-style checkmarks
if (dd.id === "dd-select") {
menu.querySelectorAll(".menu-item--check").forEach(function (item) {
item.addEventListener("click", function () {
menu.querySelectorAll(".menu-item--check").forEach(function (i) {
i.classList.remove("is-checked");
});
item.classList.add("is-checked");
var valEl = document.getElementById("dd-select-val");
if (valEl) valEl.textContent = "Sort: " + item.dataset.val;
closeDropdown(dd);
});
});
}
});
document.addEventListener("click", closeAll);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeAll();
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dropdown Menu</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h1 class="demo-title">Dropdown Menu</h1>
<p class="demo-sub">Keyboard-navigable menus with icons, dividers, and destructive items.</p>
<div class="triggers">
<!-- Action menu -->
<div class="dropdown" id="dd-action">
<button class="btn btn--ghost dropdown-trigger" aria-haspopup="true" aria-expanded="false">
Actions <span aria-hidden="true">▾</span>
</button>
<ul class="dropdown-menu" role="menu">
<li role="none"><button class="menu-item" role="menuitem"><span class="mi-icon">✏</span> Edit</button></li>
<li role="none"><button class="menu-item" role="menuitem"><span class="mi-icon">⧉</span> Duplicate</button></li>
<li role="none"><button class="menu-item" role="menuitem"><span class="mi-icon">⇪</span> Export</button></li>
<li role="none" class="menu-divider" aria-hidden="true"></li>
<li role="none"><button class="menu-item menu-item--danger" role="menuitem"><span class="mi-icon">🗑</span> Delete</button></li>
</ul>
</div>
<!-- Account menu -->
<div class="dropdown" id="dd-account">
<button class="btn btn--avatar dropdown-trigger" aria-haspopup="true" aria-expanded="false">
<span class="avatar-sm">AK</span> Alex Kim <span aria-hidden="true">▾</span>
</button>
<ul class="dropdown-menu" role="menu">
<li class="menu-header" role="none"><p class="mh-name">Alex Kim</p><p class="mh-email">alex@example.com</p></li>
<li role="none" class="menu-divider" aria-hidden="true"></li>
<li role="none"><button class="menu-item" role="menuitem"><span class="mi-icon">👤</span> Profile</button></li>
<li role="none"><button class="menu-item" role="menuitem"><span class="mi-icon">⚙</span> Settings</button></li>
<li role="none"><button class="menu-item" role="menuitem" disabled><span class="mi-icon">💳</span> Billing (soon)</button></li>
<li role="none" class="menu-divider" aria-hidden="true"></li>
<li role="none"><button class="menu-item menu-item--danger" role="menuitem"><span class="mi-icon">→</span> Sign out</button></li>
</ul>
</div>
<!-- Select-style with checkmarks -->
<div class="dropdown" id="dd-select">
<button class="btn btn--ghost dropdown-trigger" aria-haspopup="listbox" aria-expanded="false">
<span id="dd-select-val">Sort: Newest</span> <span aria-hidden="true">▾</span>
</button>
<ul class="dropdown-menu" role="listbox" aria-label="Sort options">
<li role="none"><button class="menu-item menu-item--check is-checked" role="option" data-val="Newest">Newest first</button></li>
<li role="none"><button class="menu-item menu-item--check" role="option" data-val="Oldest">Oldest first</button></li>
<li role="none"><button class="menu-item menu-item--check" role="option" data-val="A–Z">Name A–Z</button></li>
<li role="none"><button class="menu-item menu-item--check" role="option" data-val="Popular">Most popular</button></li>
</ul>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import React, { useState, useRef, useEffect } from "react";
import {
View,
Text,
TouchableOpacity,
Animated,
Modal,
StyleSheet,
Dimensions,
Pressable,
} from "react-native";
interface MenuItem {
label: string;
icon?: string;
onPress: () => void;
disabled?: boolean;
destructive?: boolean;
separator?: boolean;
}
interface DropdownMenuProps {
trigger: React.ReactNode;
items: MenuItem[];
}
function DropdownMenu({ trigger, items }: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const [triggerLayout, setTriggerLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
const scale = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(0)).current;
const triggerRef = useRef<View>(null);
const show = () => {
triggerRef.current?.measureInWindow((x, y, width, height) => {
setTriggerLayout({ x, y, width, height });
setOpen(true);
});
};
useEffect(() => {
if (open) {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
tension: 200,
friction: 15,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 150,
useNativeDriver: true,
}),
]).start();
}
}, [open]);
const hide = () => {
Animated.parallel([
Animated.timing(scale, {
toValue: 0,
duration: 120,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0,
duration: 120,
useNativeDriver: true,
}),
]).start(() => {
setOpen(false);
});
};
const screenWidth = Dimensions.get("window").width;
const menuWidth = 200;
let menuLeft = triggerLayout.x;
if (menuLeft + menuWidth > screenWidth - 16) {
menuLeft = screenWidth - menuWidth - 16;
}
return (
<View ref={triggerRef}>
<TouchableOpacity onPress={show} activeOpacity={0.7}>
{trigger}
</TouchableOpacity>
<Modal visible={open} transparent animationType="none">
<Pressable style={styles.overlay} onPress={hide}>
<Animated.View
style={[
styles.menu,
{
top: triggerLayout.y + triggerLayout.height + 4,
left: menuLeft,
width: menuWidth,
opacity,
transform: [{ scale }],
},
]}
>
{items.map((item, i) => {
if (item.separator) {
return <View key={i} style={styles.separator} />;
}
return (
<TouchableOpacity
key={i}
style={[styles.menuItem, item.disabled && styles.menuItemDisabled]}
onPress={() => {
if (!item.disabled) {
hide();
item.onPress();
}
}}
activeOpacity={item.disabled ? 1 : 0.6}
>
{item.icon && <Text style={styles.menuIcon}>{item.icon}</Text>}
<Text
style={[
styles.menuLabel,
item.destructive && styles.destructiveLabel,
item.disabled && styles.disabledLabel,
]}
>
{item.label}
</Text>
</TouchableOpacity>
);
})}
</Animated.View>
</Pressable>
</Modal>
</View>
);
}
// --- Demo ---
export default function App() {
const [status, setStatus] = useState("Select an option");
const items: MenuItem[] = [
{ label: "Edit", icon: "✏️", onPress: () => setStatus("Edit pressed") },
{ label: "Duplicate", icon: "📄", onPress: () => setStatus("Duplicate pressed") },
{ label: "Share", icon: "🔗", onPress: () => setStatus("Share pressed") },
{
label: "Archive",
icon: "📦",
onPress: () => {},
disabled: true,
separator: true,
},
{
label: "Delete",
icon: "🗑️",
onPress: () => setStatus("Delete pressed"),
destructive: true,
separator: true,
},
];
// Build items with separators inserted
const menuItems: MenuItem[] = [];
for (const item of items) {
if (item.separator) {
menuItems.push({
label: "",
onPress: () => {},
separator: true,
});
}
menuItems.push(item);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Dropdown Menu</Text>
<Text style={styles.status}>{status}</Text>
<View style={{ marginTop: 24 }}>
<DropdownMenu
trigger={
<View style={styles.triggerButton}>
<Text style={styles.triggerText}>Options ▾</Text>
</View>
}
items={menuItems}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
alignItems: "center",
paddingTop: 120,
},
title: {
color: "#f8fafc",
fontSize: 24,
fontWeight: "700",
marginBottom: 8,
},
status: {
color: "#94a3b8",
fontSize: 14,
},
triggerButton: {
backgroundColor: "#1e293b",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: "#334155",
},
triggerText: {
color: "#f8fafc",
fontSize: 16,
fontWeight: "600",
},
overlay: {
flex: 1,
},
menu: {
position: "absolute",
backgroundColor: "#1e293b",
borderRadius: 12,
borderWidth: 1,
borderColor: "#334155",
paddingVertical: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 12,
},
menuItem: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 10,
},
menuItemDisabled: {
opacity: 0.4,
},
menuIcon: {
fontSize: 16,
marginRight: 10,
},
menuLabel: {
color: "#f8fafc",
fontSize: 15,
},
destructiveLabel: {
color: "#ef4444",
},
disabledLabel: {
color: "#64748b",
},
separator: {
height: 1,
backgroundColor: "#334155",
marginVertical: 4,
marginHorizontal: 10,
},
});Dropdown Menu
An accessible action menu that opens on button click with full keyboard navigation.
Features
- Arrow keys navigate items
Enter/Spaceactivates itemEscapecloses menu- Closes on outside click
- Dividers and disabled items
- Destructive (red) item variant
- Icon support per item
Demo variants
- Action menu — Edit / Duplicate / Delete with icons
- Account menu — avatar trigger with Profile / Settings / Sign out
- Select-like — checkmark on selected option