This article provides a comprehensive guide to implementing and using dropdown components, covering their core functionalities, use cases, and best practices across various technologies like HTML/CSS/JavaScript, React, and Vue.js. It also delves into advanced features such as multi-select and searchable dropdowns, ensuring accessibility and customization, and addresses common issues with troubleshooting tips. Real-world examples and performance optimization strategies are included, alongside a detailed explanation of keyboard navigation, styling, and ARIA compliance for accessibility.
1. Component Overview
A dropdown component (also known as a select menu or combo box) is a UI element that allows users to select one or more options from a collapsible list. When inactive, it typically displays the currently selected option or a placeholder. When activated, it expands to reveal a list of available options.
Common Use Cases
Form Inputs: Selecting from predefined options in forms (e.g., country selection, categories)
Navigation: Implementing space-efficient navigation menus
Filtering: Allowing users to filter content by specific criteria
Settings: Providing configuration options in a compact interface
Language Selection: Offering language switching functionality
Dropdowns are particularly valuable when you need to present multiple options while conserving screen space, especially on mobile devices.
2. Implementation
Let's explore how to implement dropdown components across different technologies.
HTML/CSS/JavaScript Implementation
Basic HTML Structure
<div class="dropdown">
<button class="dropdown-toggle" aria-haspopup="listbox" aria-expanded="false">
Select an option
<span class="dropdown-icon">▼</span>
</button>
<ul class="dropdown-menu" role="listbox" hidden>
<li role="option" tabindex="-1">Option 1</li>
<li role="option" tabindex="-1">Option 2</li>
<li role="option" tabindex="-1">Option 3</li>
<li role="option" tabindex="-1">Option 4</li>
</ul>
</div>
CSS Styling
.dropdown {
position: relative;
display: inline-block;
width: 200px;
}
.dropdown-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 15px;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.dropdown-toggle:hover {
border-color: #9ca3af;
}
.dropdown-toggle:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin-top: 4px;
padding: 5px 0;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 4px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 10;
max-height: 200px;
overflow-y: auto;
}
.dropdown-menu li {
padding: 8px 15px;
cursor: pointer;
list-style: none;
}
.dropdown-menu li:hover {
background-color: #f3f4f6;
}
.dropdown-menu li[aria-selected="true"] {
background-color: #e5e7eb;
font-weight: 500;
}
.dropdown-icon {
transition: transform 0.2s ease;
}
.dropdown-toggle[aria-expanded="true"] .dropdown-icon {
transform: rotate(180deg);
}
JavaScript Functionality
document.addEventListener('DOMContentLoaded', () => {
const dropdownToggle = document.querySelector('.dropdown-toggle');
const dropdownMenu = document.querySelector('.dropdown-menu');
const options = dropdownMenu.querySelectorAll('li');
dropdownToggle.addEventListener('click', () => {
const expanded = dropdownToggle.getAttribute('aria-expanded') === 'true';
dropdownToggle.setAttribute('aria-expanded', !expanded);
dropdownMenu.hidden = expanded;
if (!expanded) {
options[0].focus();
}
});
document.addEventListener('click', (event) => {
if (!dropdownToggle.contains(event.target) && !dropdownMenu.contains(event.target)) {
dropdownToggle.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
}
});
options.forEach(option => {
option.addEventListener('click', () => {
options.forEach(opt => opt.setAttribute('aria-selected', 'false'));
option.setAttribute('aria-selected', 'true');
dropdownToggle.textContent = option.textContent;
const icon = document.createElement('span');
icon.className = 'dropdown-icon';
icon.textContent = '▼';
dropdownToggle.appendChild(icon);
dropdownToggle.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
dropdownToggle.focus();
});
});
dropdownMenu.addEventListener('keydown', (event) => {
const currentOption = document.activeElement;
let nextOption;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
nextOption = currentOption.nextElementSibling || options[0];
nextOption.focus();
break;
case 'ArrowUp':
event.preventDefault();
nextOption = currentOption.previousElementSibling || options[options.length - 1];
nextOption.focus();
break;
case 'Enter':
case ' ':
event.preventDefault();
currentOption.click();
break;
case 'Escape':
event.preventDefault();
dropdownToggle.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
dropdownToggle.focus();
break;
}
});
dropdownToggle.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
dropdownToggle.click();
}
});
});
React Implementation with shadcn/ui
shadcn/ui provides a well-designed, accessible dropdown component. Here's how to implement it:
"use client"
import * as React from "react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { ChevronDown } from 'lucide-react'
interface Option {
label: string
value: string
}
interface DropdownProps {
options: Option[]
placeholder?: string
onSelect: (value: string) => void
defaultValue?: string
}
export function Dropdown({
options,
placeholder = "Select an option",
onSelect,
defaultValue
}: DropdownProps) {
const [selectedOption, setSelectedOption] = React.useState<Option | undefined>(
defaultValue ? options.find(opt => opt.value === defaultValue) : undefined
)
const handleSelect = (option: Option) => {
setSelectedOption(option)
onSelect(option.value)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{selectedOption ? selectedOption.label : placeholder}
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
{options.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => handleSelect(option)}
className={selectedOption?.value === option.value ? "bg-muted font-medium" : ""}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
Usage Example
import { Dropdown } from "@/components/dropdown-menu"
export default function Home() {
const countryOptions = [
{ label: "United States", value: "us" },
{ label: "United Kingdom", value: "uk" },
{ label: "Canada", value: "ca" },
{ label: "Australia", value: "au" },
{ label: "Germany", value: "de" },
{ label: "France", value: "fr" },
{ label: "Japan", value: "jp" },
]
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">Country Selection</h1>
<Dropdown
options={countryOptions}
placeholder="Select a country"
onSelect={(value) => console.log(`Selected country: ${value}`)}
/>
</div>
)
}
Vue.js Implementation
Vue Dropdown
<template>
<div class="dropdown-container" v-click-outside="closeDropdown">
<button
class="dropdown-toggle"
@click="toggleDropdown"
:aria-expanded="isOpen.toString()"
aria-haspopup="listbox"
ref="toggleButton"
>
{{ selectedOption ? selectedOption.label : placeholder }}
<span class="dropdown-icon" :class="{ 'rotate': isOpen }">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</span>
</button>
<transition name="dropdown">
<ul
v-if="isOpen"
class="dropdown-menu"
role="listbox"
:aria-activedescendant="selectedOption ? `option-${selectedOption.value}` : ''"
ref="dropdownMenu"
@keydown="handleKeyDown"
>
<li
v-for="option in options"
:key="option.value"
:id="`option-${option.value}`"
role="option"
:aria-selected="selectedOption && selectedOption.value === option.value"
:class="{ 'selected': selectedOption && selectedOption.value === option.value }"
@click="selectOption(option)"
tabindex="-1"
ref="optionElements"
>
{{ option.label }}
</li>
</ul>
</transition>
</div>
</template>
<script setup>
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
options: {
type: Array,
required: true,
validator: (options) => options.every(option => 'label' in option && 'value' in option)
},
placeholder: {
type: String,
default: 'Select an option'
},
modelValue: {
type: [String, Number],
default: null
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const isOpen = ref(false)
const toggleButton = ref(null)
const dropdownMenu = ref(null)
const optionElements = ref([])
const focusedOptionIndex = ref(-1)
const selectedOption = computed(() => {
if (!props.modelValue) return null
return props.options.find(option => option.value === props.modelValue)
})
const toggleDropdown = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
nextTick(() => {
focusedOptionIndex.value = selectedOption.value
? props.options.findIndex(option => option.value === selectedOption.value.value)
: 0
if (optionElements.value[focusedOptionIndex.value]) {
optionElements.value[focusedOptionIndex.value].focus()
}
})
}
}
const closeDropdown = () => {
isOpen.value = false
focusedOptionIndex.value = -1
}
const selectOption = (option) => {
emit('update:modelValue', option.value)
emit('change', option.value)
closeDropdown()
toggleButton.value.focus()
}
const handleKeyDown = (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (focusedOptionIndex.value < props.options.length - 1) {
focusedOptionIndex.value++
optionElements.value[focusedOptionIndex.value].focus()
}
break
case 'ArrowUp':
event.preventDefault()
if (focusedOptionIndex.value > 0) {
focusedOptionIndex.value--
optionElements.value[focusedOptionIndex.value].focus()
}
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedOptionIndex.value >= 0) {
selectOption(props.options[focusedOptionIndex.value])
}
break
case 'Escape':
event.preventDefault()
closeDropdown()
toggleButton.value.focus()
break
}
}
const vClickOutside = {
mounted(el, binding) {
el._clickOutside = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutside)
}
}
onBeforeUnmount(() => {
if (toggleButton.value) {
toggleButton.value._clickOutside = null
}
})
</script>
<style scoped>
.dropdown-container {
position: relative;
width: 100%;
max-width: 300px;
}
.dropdown-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 16px;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-align: left;
transition: all 0.2s ease;
}
.dropdown-toggle:hover {
border-color: #9ca3af;
}
.dropdown-toggle:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
.dropdown-icon {
transition: transform 0.2s ease;
}
.dropdown-icon.rotate {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
max-height: 200px;
overflow-y: auto;
padding: 4px 0;
margin: 0;
background-color: #ffffff;
border: 1px solid #d1d5db;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 10;
list-style: none;
}
.dropdown-menu li {
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.dropdown-menu li:hover {
background-color: #f3f4f6;
}
.dropdown-menu li:focus {
outline: none;
background-color: #e5e7eb;
}
.dropdown-menu li.selected {
background-color: #e5e7eb;
font-weight: 500;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.2s, transform 0.2s;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
Usage Example
<template>
<div class="container">
<h1>Country Selection</h1>
<Dropdown
v-model="selectedCountry"
:options="countryOptions"
placeholder="Select a country"
@change="handleCountryChange"
/>
<p v-if="selectedCountry" class="selection-info">
You selected: {{ getSelectedCountryLabel() }}
</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import Dropdown from './Dropdown.vue'
const countryOptions = [
{ label: "United States", value: "us" },
{ label: "United Kingdom", value: "uk" },
{ label: "Canada", value: "ca" },
{ label: "Australia", value: "au" },
{ label: "Germany", value: "de" },
{ label: "France", value: "fr" },
{ label: "Japan", value: "jp" },
]
const selectedCountry = ref(null)
const handleCountryChange = (value) => {
console.log(`Selected country: ${value}`)
}
const getSelectedCountryLabel = () => {
const country = countryOptions.find(option => option.value === selectedCountry.value)
return country ? country.label : ''
}
</script>
<style scoped>
.container {
max-width: 500px;
margin: 0 auto;
padding: 2rem;
font-family: Arial, sans-serif;
}
h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.selection-info {
margin-top: 1rem;
padding: 0.5rem;
background-color: #f3f4f6;
border-radius: 4px;
}
</style>
3. Functionality
Let's explore the core functionalities of dropdown components in detail.
Opening and Closing the Dropdown
The dropdown should toggle between open and closed states when the user clicks the trigger button. It should also close when the user clicks outside the dropdown or presses the Escape key.
toggleButton.addEventListener('click', () => {
const expanded = toggleButton.getAttribute('aria-expanded') === 'true';
toggleButton.setAttribute('aria-expanded', !expanded);
dropdownMenu.hidden = expanded;
});
document.addEventListener('click', (event) => {
if (!toggleButton.contains(event.target) && !dropdownMenu.contains(event.target)) {
toggleButton.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
}
});
dropdownMenu.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
toggleButton.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
toggleButton.focus();
}
});
Selecting Options
When a user selects an option, the dropdown should:
Update the selected value
Update the display text in the toggle button
Close the dropdown
Trigger any associated callbacks or events
options.forEach(option => {
option.addEventListener('click', () => {
options.forEach(opt => opt.setAttribute('aria-selected', 'false'));
option.setAttribute('aria-selected', 'true');
toggleButton.textContent = option.textContent;
toggleButton.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
const event = new CustomEvent('change', {
detail: { value: option.getAttribute('data-value') }
});
toggleButton.dispatchEvent(event);
});
});
Keyboard Navigation
Proper keyboard navigation is essential for accessibility. Users should be able to:
Open the dropdown with Enter, Space, or Arrow Down
Navigate options with Arrow Up and Arrow Down
Select an option with Enter or Space
Close the dropdown with Escape
dropdownMenu.addEventListener('keydown', (event) => {
const currentOption = document.activeElement;
let nextOption;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
nextOption = currentOption.nextElementSibling || options[0];
nextOption.focus();
break;
case 'ArrowUp':
event.preventDefault();
nextOption = currentOption.previousElementSibling || options[options.length - 1];
nextOption.focus();
break;
case 'Enter':
case ' ':
event.preventDefault();
currentOption.click();
break;
case 'Escape':
event.preventDefault();
toggleButton.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
toggleButton.focus();
break;
}
});
toggleButton.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
toggleButton.click();
}
});
4. Customization
Dropdown components can be customized in various ways to match your application's design system.
Styling the Dropdown
Custom Themes
:root {
--dropdown-bg: #ffffff;
--dropdown-border: #d1d5db;
--dropdown-text: #111827;
--dropdown-hover-bg: #f3f4f6;
--dropdown-selected-bg: #e5e7eb;
--dropdown-focus-border: #3b82f6;
--dropdown-focus-shadow: rgba(59, 130, 246, 0.3);
--dropdown-radius: 4px;
}
.dark-theme {
--dropdown-bg: #1f2937;
--dropdown-border: #374151;
--dropdown-text: #f9fafb;
--dropdown-hover-bg: #374151;
--dropdown-selected-bg: #4b5563;
--dropdown-focus-border: #60a5fa;
--dropdown-focus-shadow: rgba(96, 165, 250, 0.3);
}
.dropdown-toggle {
background-color: var(--dropdown-bg);
border: 1px solid var(--dropdown-border);
color: var(--dropdown-text);
border-radius: var(--dropdown-radius);
}
.dropdown-menu {
background-color: var(--dropdown-bg);
border: 1px solid var(--dropdown-border);
border-radius: var(--dropdown-radius);
}
.dropdown-menu li:hover {
background-color: var(--dropdown-hover-bg);
}
.dropdown-menu li[aria-selected="true"] {
background-color: var(--dropdown-selected-bg);
}
Custom Selection Indicators
.dropdown-menu li[aria-selected="true"] {
position: relative;
padding-right: 30px;
}
.dropdown-menu li[aria-selected="true"]::after {
content: "✓";
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #10b981;
}
.dropdown-menu li[aria-selected="true"]::after {
content: "";
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2310b981' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
Custom Animations
Add smooth animations to enhance the user experience:
.dropdown-menu {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-menu.visible {
opacity: 1;
transform: translateY(0);
}
.dropdown-icon {
transition: transform 0.2s ease;
}
.dropdown-toggle[aria-expanded="true"] .dropdown-icon {
transform: rotate(180deg);
}
Custom Dropdown Variants
You can create different variants of the dropdown for different contexts:
.dropdown-primary .dropdown-toggle {
background-color: #3b82f6;
color: white;
border-color: #2563eb;
}
.dropdown-primary .dropdown-toggle:hover {
background-color: #2563eb;
}
.dropdown-outline .dropdown-toggle {
background-color: transparent;
border: 2px solid #d1d5db;
}
.dropdown-outline .dropdown-toggle:hover {
border-color: #9ca3af;
}
.dropdown-minimal .dropdown-toggle {
background-color: transparent;
border: none;
padding: 8px 0;
}
.dropdown-minimal .dropdown-toggle:hover {
color: #3b82f6;
}
.dropdown-minimal .dropdown-menu {
margin-top: 8px;
}
5. Accessibility
Ensuring your dropdown component is accessible is crucial for users with disabilities.
ARIA Attributes
Use appropriate ARIA attributes to make your dropdown accessible to screen readers:
<div class="dropdown">
<button
class="dropdown-toggle"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="dropdown-label"
id="dropdown-button"
>
<span id="dropdown-label">Select an option</span>
<span class="dropdown-icon" aria-hidden="true">▼</span>
</button>
<ul
class="dropdown-menu"
role="listbox"
aria-labelledby="dropdown-label"
id="dropdown-menu"
tabindex="-1"
hidden
>
<li
role="option"
id="option-1"
tabindex="-1"
aria-selected="false"
>
Option 1
</li>
<li
role="option"
id="option-2"
tabindex="-1"
aria-selected="false"
>
Option 2
</li>
</ul>
</div>
Keyboard Navigation
Ensure users can navigate and interact with the dropdown using only a keyboard:
Tab: Focus on the dropdown toggle
Enter/Space/Arrow Down: Open the dropdown
Arrow Up/Down: Navigate between options
Enter/Space: Select the focused option
Escape: Close the dropdown
Tab (when open): Should be trapped within the dropdown
Focus Management
Proper focus management is essential for keyboard users:
toggleButton.addEventListener('click', () => {
const expanded = toggleButton.getAttribute('aria-expanded') === 'true';
toggleButton.setAttribute('aria-expanded', !expanded);
dropdownMenu.hidden = expanded;
if (!expanded) {
const selectedOption = dropdownMenu.querySelector('[aria-selected="true"]') ||
dropdownMenu.querySelector('[role="option"]');
if (selectedOption) {
selectedOption.focus();
}
}
});
function closeDropdown() {
toggleButton.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
toggleButton.focus();
}
Screen Reader Announcements
For dynamic changes, use ARIA live regions to announce updates to screen readers:
<div class="dropdown">
<!-- Dropdown markup -->
<!-- Live region for announcements -->
<div aria-live="polite" class="sr-only" id="dropdown-announcement"></div>
</div>
function announceSelection(option) {
const announcement = document.getElementById('dropdown-announcement');
announcement.textContent = `Selected ${option.textContent}`;
}
options.forEach(option => {
option.addEventListener('click', () => {
announceSelection(option);
});
});
Color Contrast
Ensure sufficient color contrast for all users:
Text color vs background color should have a contrast ratio of at least 4.5:1
Focus indicators should be clearly visible
Selected state should be distinguishable
Testing Accessibility
Test your dropdown component with various assistive technologies:
Screen readers: Test with popular screen readers like NVDA, JAWS, or VoiceOver
Keyboard-only: Navigate through the dropdown using only keyboard
High contrast mode: Ensure the dropdown is usable in high contrast mode
Zoom: Test the dropdown at different zoom levels (up to 200%)
6. Advanced Features
Let's explore some advanced features you can add to your dropdown components.
Multi-Select Dropdown
A multi-select dropdown allows users to select multiple options:
"use client"
import * as React from "react"
import { X, Check, ChevronDown } from 'lucide-react'
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Badge } from "@/components/ui/badge"
interface Option {
label: string
value: string
}
interface MultiSelectProps {
options: Option[]
selected: string[]
onChange: (values: string[]) => void
placeholder?: string
emptyMessage?: string
}
export function MultiSelect({
options,
selected,
onChange,
placeholder = "Select options",
emptyMessage = "No options found.",
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const handleSelect = (value: string) => {
const newSelected = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value]
onChange(newSelected)
}
const handleRemove = (value: string) => {
onChange(selected.filter((item) => item !== value))
}
const selectedLabels = selected.map(value => {
const option = options.find(opt => opt.value === value)
return option ? option.label : value
})
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selected.length > 0 ? (
<div className="flex flex-wrap gap-1 max-w-[90%] overflow-hidden">
{selected.length <= 2 ? (
selectedLabels.map((label) => (
<Badge key={label} variant="secondary" className="mr-1">
{label}
</Badge>
))
) : (
<Badge variant="secondary">
{selected.length} selected
</Badge>
)}
</div>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder="Search options..." />
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<div className="flex items-center gap-2 w-full">
<div className={`flex-shrink-0 rounded-sm border p-0.5 ${
isSelected ? "bg-primary border-primary" : "border-muted"
}`}>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span>{option.label}</span>
</div>
</CommandItem>
)
})}
</CommandGroup>
</Command>
{selected.length > 0 && (
<div className="border-t p-2">
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => onChange([])}
>
Clear all
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}
Usage Example
import { MultiSelect } from "@/components/multi-select-dropdown"
import { useState } from "react"
export default function SkillsSelector() {
const [selectedSkills, setSelectedSkills] = useState<string[]>([])
const skillOptions = [
{ label: "JavaScript", value: "js" },
{ label: "TypeScript", value: "ts" },
{ label: "React", value: "react" },
{ label: "Vue", value: "vue" },
{ label: "Angular", value: "angular" },
{ label: "Node.js", value: "node" },
{ label: "Python", value: "python" },
{ label: "Java", value: "java" },
{ label: "C#", value: "csharp" },
{ label: "PHP", value: "php" },
]
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">Select Your Skills</h1>
<MultiSelect
options={skillOptions}
selected={selectedSkills}
onChange={setSelectedSkills}
placeholder="Select skills"
/>
{selectedSkills.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-md">
<h2 className="font-medium mb-2">Selected Skills:</h2>
<ul className="list-disc pl-5">
{selectedSkills.map(value => {
const option = skillOptions.find(opt => opt.value === value)
return option ? (
<li key={value}>{option.label}</li>
) : null
})}
</ul>
</div>
)}
</div>
)
}
Searchable Dropdown
A searchable dropdown allows users to filter options by typing:
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, Search } from 'lucide-react'
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface Option {
label: string
value: string
}
interface SearchableDropdownProps {
options: Option[]
value?: string
onValueChange: (value: string) => void
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
}
export function SearchableDropdown({
options,
value,
onValueChange,
placeholder = "Select an option",
searchPlaceholder = "Search options...",
emptyMessage = "No options found.",
}: SearchableDropdownProps) {
const [open, setOpen] = React.useState(false)
const selectedOption = options.find(option => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedOption ? selectedOption.label : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandInput placeholder={searchPlaceholder} className="border-0 focus:ring-0" />
</div>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup className="max-h-60 overflow-auto">
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onValueChange(option.value)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === option.value ? "opacity-100" : "opacity-0"
}`}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)
}
Usage Example
import { SearchableDropdown } from "@/components/searchable-dropdown"
import { useState } from "react"
export default function CountrySelector() {
const [selectedCountry, setSelectedCountry] = useState<string>("")
const countryOptions = [
{ label: "Afghanistan", value: "af" },
{ label: "Albania", value: "al" },
{ label: "Algeria", value: "dz" },
{ label: "United States", value: "us" },
{ label: "United Kingdom", value: "uk" },
{ label: "Zimbabwe", value: "zw" },
]
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">Country Selection</h1>
<SearchableDropdown
options={countryOptions}
value={selectedCountry}
onValueChange={setSelectedCountry}
placeholder="Select a country"
searchPlaceholder="Type to search countries..."
/>
{selectedCountry && (
<div className="mt-4 p-4 bg-muted rounded-md">
<p>Selected country: {countryOptions.find(c => c.value === selectedCountry)?.label}</p>
</div>
)}
</div>
)
}
Async Data Loading
Load dropdown options asynchronously from an API:
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface Option {
label: string
value: string
}
interface AsyncDropdownProps {
fetchOptions: (query: string) => Promise<Option[]>
value?: string
onValueChange: (value: string) => void
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
loadingMessage?: string
}
export function AsyncDropdown({
fetchOptions,
value,
onValueChange,
placeholder = "Select an option",
searchPlaceholder = "Search options...",
emptyMessage = "No options found.",
loadingMessage = "Loading options...",
}: AsyncDropdownProps) {
const [open, setOpen] = React.useState(false)
const [options, setOptions] = React.useState<Option[]>([])
const [loading, setLoading] = React.useState(false)
const [search, setSearch] = React.useState("")
const [selectedLabel, setSelectedLabel] = React.useState<string>("")
const debouncedSearch = React.useRef<NodeJS.Timeout>()
React.useEffect(() => {
if (open && options.length === 0 && !loading) {
loadOptions("")
}
}, [open])
React.useEffect(() => {
if (value) {
const option = options.find(opt => opt.value === value)
if (option) {
setSelectedLabel(option.label)
}
}
}, [value, options])
const loadOptions = React.useCallback(async (query: string) => {
setLoading(true)
try {
const results = await fetchOptions(query)
setOptions(results)
} catch (error) {
console.error("Error loading options:", error)
} finally {
setLoading(false)
}
}, [fetchOptions])
const handleSearch = (value: string) => {
setSearch(value)
if (debouncedSearch.current) {
clearTimeout(debouncedSearch.current)
}
debouncedSearch.current = setTimeout(() => {
loadOptions(value)
}, 300)
}
const handleSelect = (option: Option) => {
onValueChange(option.value)
setSelectedLabel(option.label)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{value ? selectedLabel : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={searchPlaceholder}
value={search}
onValueChange={handleSearch}
/>
{loading ? (
<div className="py-6 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
<p className="text-sm text-muted-foreground">{loadingMessage}</p>
</div>
) : (
<>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup className="max-h-60 overflow-auto">
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option)}
>
<Check
className={`mr-2 h-4 w-4 ${
value === option.value ? "opacity-100" : "opacity-0"
}`}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</>
)}
</Command>
</PopoverContent>
</Popover>
)
}
Usage Example
import { AsyncDropdown } from "@/components/async-dropdown"
import { useState } from "react"
interface User {
id: number
name: string
username: string
email: string
}
export default function UserSelector() {
const [selectedUser, setSelectedUser] = useState<string>("")
const fetchUsers = async (query: string) => {
await new Promise(resolve => setTimeout(resolve, 500))
const response = await fetch('https://jsonplaceholder.typicode.com/users')
const users: User[] = await response.json()
const filteredUsers = query
? users.filter(user =>
user.name.toLowerCase().includes(query.toLowerCase()) ||
user.username.toLowerCase().includes(query.toLowerCase())
)
: users
return filteredUsers.map(user => ({
label: `${user.name} (@${user.username})`,
value: user.id.toString()
}))
}
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">User Selection</h1>
<AsyncDropdown
fetchOptions={fetchUsers}
value={selectedUser}
onValueChange={setSelectedUser}
placeholder="Select a user"
searchPlaceholder="Search users..."
/>
{selectedUser && (
<div className="mt-4 p-4 bg-muted rounded-md">
<p>Selected user ID: {selectedUser}</p>
</div>
)}
</div>
)
}
Grouped Options
Organize dropdown options into logical groups:
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface Option {
label: string
value: string
}
interface OptionGroup {
label: string
options: Option[]
}
interface GroupedDropdownProps {
groups: OptionGroup[]
value?: string
onValueChange: (value: string) => void
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
}
export function GroupedDropdown({
groups,
value,
onValueChange,
placeholder = "Select an option",
searchPlaceholder = "Search options...",
emptyMessage = "No options found.",
}: GroupedDropdownProps) {
const [open, setOpen] = React.useState(false)
const selectedOption = React.useMemo(() => {
if (!value) return undefined
for (const group of groups) {
const option = group.options.find(opt => opt.value === value)
if (option) return option
}
return undefined
}, [value, groups])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedOption ? selectedOption.label : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
{groups.map((group, index) => (
<React.Fragment key={group.label}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={group.label}>
{group.options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onValueChange(option.value)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === option.value ? "opacity-100" : "opacity-0"
}`}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</React.Fragment>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
Usage Example
import { GroupedDropdown } from "@/components/grouped-dropdown"
import { useState } from "react"
export default function FoodSelector() {
const [selectedFood, setSelectedFood] = useState<string>("")
const foodGroups = [
{
label: "Fruits",
options: [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Orange", value: "orange" },
{ label: "Strawberry", value: "strawberry" },
]
},
{
label: "Vegetables",
options: [
{ label: "Carrot", value: "carrot" },
{ label: "Broccoli", value: "broccoli" },
{ label: "Spinach", value: "spinach" },
{ label: "Tomato", value: "tomato" },
]
},
{
label: "Proteins",
options: [
{ label: "Chicken", value: "chicken" },
{ label: "Beef", value: "beef" },
{ label: "Fish", value: "fish" },
{ label: "Tofu", value: "tofu" },
]
}
]
return (
<div className="p-8 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">Food Selection</h1>
<GroupedDropdown
groups={foodGroups}
value={selectedFood}
onValueChange={setSelectedFood}
placeholder="Select a food"
searchPlaceholder="Search foods..."
/>
{selectedFood && (
<div className="mt-4 p-4 bg-muted rounded-md">
<p>Selected food: {selectedFood}</p>
</div>
)}
</div>
)
}
7. Best Practices
Follow these best practices to create effective dropdown components:
Virtualization for Large Lists: Use virtualization libraries like react-window or react-virtualized for dropdowns with many options to improve performance.
import { FixedSizeList as List } from 'react-window';
const renderOption = ({ index, style }) => {
const option = options[index];
return (
<div
style={style}
onClick={() => handleSelect(option)}
className={`option ${selectedValue === option.value ? 'selected' : ''}`}
>
{option.label}
</div>
);
};
<List
height={200}
itemCount={options.length}
itemSize={35}
width="100%"
>
{renderOption}
</List>
Debounced Search: Implement debouncing for search inputs to prevent excessive API calls or filtering operations.
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
const debouncedSearch = debounce((query) => {
filterOptions(query);
}, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
Lazy Loading: Load options only when needed, especially for large datasets.
toggleButton.addEventListener('click', () => {
const expanded = toggleButton.getAttribute('aria-expanded') === 'true';
toggleButton.setAttribute('aria-expanded', !expanded);
dropdownMenu.hidden = expanded;
if (!expanded && !optionsLoaded) {
loadOptions();
optionsLoaded = true;
}
});
User Experience
Provide Clear Feedback: Always indicate the current selection and provide visual feedback for interactions.
Maintain Context: Keep the dropdown in view when scrolling, especially for long forms.
Appropriate Sizing: Make dropdown targets large enough for comfortable interaction (at least 44x44 pixels for touch targets).
Predictable Behavior: Ensure consistent behavior across your application.
Default Values: Provide sensible defaults when appropriate.
Error Handling: Display clear error messages when operations fail.
Loading States: Show loading indicators for asynchronous operations.
Common Pitfalls to Avoid
Overloading Dropdowns: Don't put too many options in a single dropdown. Consider alternative UI patterns for very large sets.
Ignoring Mobile Users: Ensure dropdowns are touch-friendly and work well on small screens.
Poor Keyboard Support: Always implement proper keyboard navigation.
Neglecting Accessibility: Make sure your dropdown is accessible to all users.
Inconsistent Styling: Maintain consistent styling with your application's design system.
Slow Performance: Optimize for performance, especially with large datasets.
Complex Nesting: Avoid deeply nested dropdown menus as they can be difficult to navigate.
8. Troubleshooting
Styling Issues
Dropdown menu appears in the wrong position:
Check if the dropdown container has position: relative
Ensure the dropdown menu has position: absolute and appropriate top/left values
Consider using a positioning library like Popper.js for complex positioning
Dropdown menu gets cut off by container boundaries:
Use overflow: visible on parent containers
Consider using a portal to render the dropdown menu at the document root
Implement dynamic positioning based on available space
Z-index issues:
Functionality Issues
Dropdown doesn't close when clicking outside:
Ensure your event listener is properly attached to the document
Check that your click handler correctly identifies clicks outside the dropdown
Verify event propagation isn't being stopped prematurely
document.addEventListener('click', (event) => {
if (!dropdownElement.contains(event.target)) {
closeDropdown();
}
});
dropdownToggle.addEventListener('click', (event) => {
event.stopPropagation();
toggleDropdown();
});
Keyboard navigation doesn't work:
Ensure focus management is implemented correctly
Check that key event listeners are attached to the right elements
Verify that default browser behaviors aren't being prevented incorrectly
Selected value doesn't update:
Check that your state management is working correctly
Ensure the change event is being triggered and handled
Verify that the display element is being updated with the new value
Accessibility Issues
Screen readers don't announce dropdown state changes:
Ensure proper ARIA attributes are used (aria-expanded, aria-activedescendant)
Use aria-live regions for dynamic content changes
Test with actual screen readers
Focus gets lost when dropdown closes:
Keyboard users can't access all functionality:
Implement complete keyboard navigation (arrows, Enter, Space, Escape)
Ensure all interactive elements are focusable
Test the component using only a keyboard
9. Real-World Examples
Let's look at some practical examples of dropdown components in different contexts:
"use client"
import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Dropdown } from "@/components/dropdown-menu"
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
email: z.string().email({
message: "Please enter a valid email address.",
}),
country: z.string().min(1, {
message: "Please select a country.",
}),
})
export function RegistrationForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
country: "",
},
})
const countryOptions = [
{ label: "United States", value: "us" },
{ label: "United Kingdom", value: "uk" },
{ label: "Canada", value: "ca" },
{ label: "Australia", value: "au" },
{ label: "Germany", value: "de" },
{ label: "France", value: "fr" },
{ label: "Japan", value: "jp" },
]
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
alert(`Form submitted with values: ${JSON.stringify(values, null, 2)}`)
}
return (
<div className="max-w-md mx-auto p-6 bg-card rounded-lg shadow-sm">
<h1 className="text-2xl font-bold mb-6">Registration Form</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormDescription>
Enter your full name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormDescription>
We'll never share your email with anyone else.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<FormControl>
<Dropdown
options={countryOptions}
placeholder="Select your country"
onSelect={field.onChange}
defaultValue={field.value}
/>
</FormControl>
<FormDescription>
Select the country where you currently reside.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">Register</Button>
</form>
</Form>
</div>
)
}
Filtering Data
"use client"
import { useState, useEffect } from "react"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table"
import { Input } from "@/components/ui/input"
import { Dropdown } from "@/components/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Search, RefreshCw } from 'lucide-react'
interface Product {
id: number
name: string
category: string
price: number
stock: number
}
const categories = [
{ label: "All Categories", value: "all" },
{ label: "Electronics", value: "electronics" },
{ label: "Clothing", value: "clothing" },
{ label: "Home & Kitchen", value: "home" },
{ label: "Books", value: "books" },
{ label: "Toys", value: "toys" },
]
const sampleProducts: Product[] = [
{ id: 1, name: "Smartphone", category: "electronics", price: 699, stock: 25 },
{ id: 2, name: "Laptop", category: "electronics", price: 1299, stock: 10 },
{ id: 3, name: "T-shirt", category: "clothing", price: 19.99, stock: 100 },
{ id: 4, name: "Jeans", category: "clothing", price: 49.99, stock: 50 },
{ id: 5, name: "Coffee Maker", category: "home", price: 89.99, stock: 15 },
{ id: 6, name: "Blender", category: "home", price: 39.99, stock: 20 },
{ id: 7, name: "Novel", category: "books", price: 14.99, stock: 30 },
{ id: 8, name: "Cookbook", category: "books", price: 24.99, stock: 25 },
{ id: 9, name: "Action Figure", category: "toys", price: 9.99, stock: 40 },
{ id: 10, name: "Board Game", category: "toys", price: 29.99, stock: 15 },
]
export function ProductTable() {
const [products, setProducts] = useState<Product[]>(sampleProducts)
const [filteredProducts, setFilteredProducts] = useState<Product[]>(sampleProducts)
const [searchQuery, setSearchQuery] = useState("")
const [categoryFilter, setCategoryFilter] = useState("all")
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setIsLoading(true)
const timer = setTimeout(() => {
let filtered = [...products]
if (searchQuery) {
filtered = filtered.filter(product =>
product.name.toLowerCase().includes(searchQuery.toLowerCase())
)
}
if (categoryFilter !== "all") {
filtered = filtered.filter(product =>
product.category === categoryFilter
)
}
setFilteredProducts(filtered)
setIsLoading(false)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, categoryFilter, products])
const resetFilters = () => {
setSearchQuery("")
setCategoryFilter("all")
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<div className="w-full sm:w-48">
<Dropdown
options={categories}
onSelect={setCategoryFilter}
defaultValue={categoryFilter}
placeholder="Select category"
/>
</div>
<Button variant="outline" size="icon" onClick={resetFilters} title="Reset filters">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{isLoading ? (
<div className="h-[300px] flex items-center justify-center">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Stock</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProducts.length > 0 ? (
filteredProducts.map((product) => (
<TableRow key={product.id}>
<TableCell className="font-medium">{product.name}</TableCell>
<TableCell className="capitalize">{product.category}</TableCell>
<TableCell className="text-right">${product.price.toFixed(2)}</TableCell>
<TableCell className="text-right">{product.stock}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
No products found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="text-sm text-muted-foreground">
Showing {filteredProducts.length} of {products.length} products
</div>
</>
)}
</div>
)
}
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Menu, User, Settings, LogOut, HelpCircle } from 'lucide-react'
interface NavItem {
label: string
href: string
icon?: React.ReactNode
}
interface NavSection {
label: string
items: NavItem[]
}
export function NavigationDropdown() {
const pathname = usePathname()
const [open, setOpen] = useState(false)
const mainNavItems: NavItem[] = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Projects", href: "/projects" },
{ label: "Tasks", href: "/tasks" },
{ label: "Calendar", href: "/calendar" },
{ label: "Reports", href: "/reports" },
]
const userNavItems: NavSection[] = [
{
label: "Account",
items: [
{ label: "Profile", href: "/profile", icon: <User className="mr-2 h-4 w-4" /> },
{ label: "Settings", href: "/settings", icon: <Settings className="mr-2 h-4 w-4" /> },
]
},
{
label: "Support",
items: [
{ label: "Help Center", href: "/help", icon: <HelpCircle className="mr-2 h-4 w-4" /> },
{ label: "Log Out", href: "/logout", icon: <LogOut className="mr-2 h-4 w-4" /> },
]
}
]
return (
<div className="flex items-center space-x-4">
{}
<div className="block md:hidden">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{mainNavItems.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<Link
href={item.href}
className={pathname === item.href ? "bg-muted font-medium" : ""}
onClick={() => setOpen(false)}
>
{item.label}
</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{userNavItems.map((section, index) => (
<div key={section.label}>
{index > 0 && <DropdownMenuSeparator />}
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{section.label}
</div>
{section.items.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<Link
href={item.href}
className="flex items-center"
onClick={() => setOpen(false)}
>
{item.icon}
{item.label}
</Link>
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{}
<div className="hidden md:flex md:items-center md:space-x-4">
{mainNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`px-3 py-2 text-sm font-medium rounded-md ${
pathname === item.href
? "bg-muted"
: "hover:bg-muted/50 transition-colors"
}`}
>
{item.label}
</Link>
))}
{}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<User className="h-5 w-5" />
<span className="sr-only">User menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{userNavItems.map((section, index) => (
<div key={section.label}>
{index > 0 && <DropdownMenuSeparator />}
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{section.label}
</div>
{section.items.map((item) => (
<DropdownMenuItem key={item.href} asChild>
<Link href={item.href} className="flex items-center">
{item.icon}
{item.label}
</Link>
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
Dropdown Component Docs
Overview
The Dropdown component provides a way to select a value from a list of options. It supports single selection, keyboard navigation, and customization options.
Installation
npm install @your-org/dropdown
yarn add @your-org/dropdown
Basic Usage
import { Dropdown } from '@your-org/dropdown';
function MyComponent() {
const options = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
];
const handleSelect = (value) => {
console.log(`Selected: ${value}`);
};
return (
<Dropdown options={options} onSelect={handleSelect} placeholder="Select an option" />
);
}
Props
| Prop | Type | Default | Description |
options | Array<{ label: string, value: string }> | Required | Array of options to display in the dropdown |
value | string | undefined | The currently selected value |
onSelect | (value: string) => void | Required | Callback function called when an option is selected |
placeholder | string | 'Select an option' | Text to display when no option is selected |
disabled | boolean | false | Whether the dropdown is disabled |
error | string | undefined | Error message to display |
className | string | undefined | Additional CSS class for the dropdown container |
menuClassName | string | undefined | Additional CSS class for the dropdown menu |
buttonClassName | string | undefined | Additional CSS class for the dropdown button |
Methods
| Method | Description |
open() | Opens the dropdown menu |
close() | Closes the dropdown menu |
toggle() | Toggles the dropdown menu |
setValue(value: string) | Programmatically sets the selected value |
Events
| Event | Description |
onOpen | Fired when the dropdown menu opens |
onClose | Fired when the dropdown menu closes |
onSelect | Fired when an option is selected |
onFocus | Fired when the dropdown receives focus |
onBlur | Fired when the dropdown loses focus |
Keyboard Navigation
| Key | Action |
Enter / Space | Opens the dropdown menu or selects the focused option |
Escape | Closes the dropdown menu |
Arrow Down | Moves focus to the next option |
Arrow Up | Moves focus to the previous option |
Home | Moves focus to the first option |
End | Moves focus to the last option |
Tab | Moves focus to the next focusable element |
Accessibility
The Dropdown component follows WAI-ARIA guidelines for accessibility:
Uses appropriate ARIA attributes (aria-haspopup, aria-expanded, role="listbox", etc.)
Supports keyboard navigation
Announces changes to screen readers
Maintains focus management
Example Dropdown Components
Basic Dropdown
<Dropdown
options={[
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
]}
onSelect={(value) => console.log(value)}
placeholder="Select a fruit"
/>
Disabled Dropdown
<Dropdown
options={[
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
]}
onSelect={(value) => console.log(value)}
placeholder="Select a fruit"
disabled={true}
/>
Dropdown with Error
<Dropdown
options={[
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
]}
onSelect={(value) => console.log(value)}
placeholder="Select a fruit"
error="Please select a valid fruit"
/>
Controlled Dropdown
function ControlledDropdown() {
const [value, setValue] = useState('banana');
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
];
return (
<Dropdown
options={options}
value={value}
onSelect={setValue}
placeholder="Select a fruit"
/>
);
}
Styling
The Dropdown component can be styled using CSS:
.dropdown-container {
}
.dropdown-toggle {
}
.dropdown-menu {
}
.dropdown-option {
}
.dropdown-option.selected {
}
.dropdown-container.disabled {
}
.dropdown-container.has-error {
}
Browser Support
The Dropdown component supports all modern browsers:
License
MIT