Dropdown Components Made Easy: A Comprehensive Example Guide

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'); // Toggle dropdown dropdownToggle.addEventListener('click', () => { const expanded = dropdownToggle.getAttribute('aria-expanded') === 'true'; dropdownToggle.setAttribute('aria-expanded', !expanded); dropdownMenu.hidden = expanded; if (!expanded) { // Focus the first option when opening options[0].focus(); } }); // Close dropdown when clicking outside document.addEventListener('click', (event) => { if (!dropdownToggle.contains(event.target) && !dropdownMenu.contains(event.target)) { dropdownToggle.setAttribute('aria-expanded', 'false'); dropdownMenu.hidden = true; } }); // Handle option selection options.forEach(option => { option.addEventListener('click', () => { // Update the selected option options.forEach(opt => opt.setAttribute('aria-selected', 'false')); option.setAttribute('aria-selected', 'true'); // Update the button text dropdownToggle.textContent = option.textContent; // Add back the dropdown icon const icon = document.createElement('span'); icon.className = 'dropdown-icon'; icon.textContent = '▼'; dropdownToggle.appendChild(icon); // Close the dropdown dropdownToggle.setAttribute('aria-expanded', 'false'); dropdownMenu.hidden = true; // Focus the toggle button dropdownToggle.focus(); }); }); // Keyboard navigation 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; } }); // Allow toggling with keyboard 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:
// components/dropdown-menu.tsx
"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
// app/page.tsx 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 } } // Custom directive for detecting clicks outside the dropdown 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) } } // Cleanup event listeners 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; } /* Transition animations */ .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.
// Toggle dropdown on button click
toggleButton.addEventListener('click', () => {
const expanded = toggleButton.getAttribute('aria-expanded') === 'true';
toggleButton.setAttribute('aria-expanded', !expanded);
dropdownMenu.hidden = expanded;
});
// Close dropdown when clicking outside
document.addEventListener('click', (event) => {
if (!toggleButton.contains(event.target) && !dropdownMenu.contains(event.target)) {
toggleButton.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
}
});
// Close dropdown on Escape key
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
// Handle option selection
options.forEach(option => {
option.addEventListener('click', () => {
// Update selected state
options.forEach(opt => opt.setAttribute('aria-selected', 'false'));
option.setAttribute('aria-selected', 'true');
// Update toggle button text
toggleButton.textContent = option.textContent;
// Close dropdown
toggleButton.setAttribute('aria-expanded', 'false');
dropdownMenu.hidden = true;
// Trigger change event
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
// Keyboard navigation within the dropdown menu
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;
}
});
// Keyboard handling for the toggle button
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 */ .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
/* Checkmark for selected option */ .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; } /* Or using an icon font/SVG */ .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:
/* Fade in/out animation */
.dropdown-menu {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-menu.visible {
opacity: 1;
transform: translateY(0);
}
/* Rotate dropdown icon */
.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:
/* Primary variant */
.dropdown-primary .dropdown-toggle {
background-color: #3b82f6;
color: white;
border-color: #2563eb;
}
.dropdown-primary .dropdown-toggle:hover {
background-color: #2563eb;
}
/* Outline variant */
.dropdown-outline .dropdown-toggle {
background-color: transparent;
border: 2px solid #d1d5db;
}
.dropdown-outline .dropdown-toggle:hover {
border-color: #9ca3af;
}
/* Minimal variant */
.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>
<!-- More options -->
</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:
// When opening the dropdown, focus the first or selected option
toggleButton.addEventListener('click', () => {
const expanded = toggleButton.getAttribute('aria-expanded') === 'true';
toggleButton.setAttribute('aria-expanded', !expanded);
dropdownMenu.hidden = expanded;
if (!expanded) {
// Find the selected option or default to the first one
const selectedOption = dropdownMenu.querySelector('[aria-selected="true"]') ||
dropdownMenu.querySelector('[role="option"]');
if (selectedOption) {
selectedOption.focus();
}
}
});
// When closing the dropdown, return focus to the toggle button
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>
// Announce selection to screen readers
function announceSelection(option) {
const announcement = document.getElementById('dropdown-announcement');
announcement.textContent = `Selected ${option.textContent}`;
}
options.forEach(option => {
option.addEventListener('click', () => {
// Other selection logic...
// Announce to screen readers
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:
// components/multi-select-dropdown.tsx
"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:
// components/searchable-dropdown.tsx
"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" }, // ... many more countries { 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:
// components/async-dropdown.tsx
"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(() => {
// Initial load
if (open && options.length === 0 && !loading) {
loadOptions("")
}
}, [open])
React.useEffect(() => {
// When value changes externally, update the selected label
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)
// Clear previous timeout
if (debouncedSearch.current) {
clearTimeout(debouncedSearch.current)
}
// Debounce search
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>("") // Function to fetch users from an API const fetchUsers = async (query: string) => { // Simulate API delay await new Promise(resolve => setTimeout(resolve, 500)) // Fetch users from JSONPlaceholder API const response = await fetch('https://jsonplaceholder.typicode.com/users') const users: User[] = await response.json() // Filter users by name if query is provided const filteredUsers = query ? users.filter(user => user.name.toLowerCase().includes(query.toLowerCase()) || user.username.toLowerCase().includes(query.toLowerCase()) ) : users // Map to dropdown options format 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:
// components/grouped-dropdown.tsx
"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)
// Find the selected option across all groups
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:
Performance Optimization
Virtualization for Large Lists: Use virtualization libraries like
react-window
orreact-virtualized
for dropdowns with many options to improve performance.import { FixedSizeList as List } from 'react-window'; // Inside your dropdown component const renderOption = ({ index, style }) => { const option = options[index]; return ( <div style={style} onClick={() => handleSelect(option)} className={`option ${selectedValue === option.value ? 'selected' : ''}`} > {option.label} </div> ); }; // In your render method <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) => { // Perform search or filtering filterOptions(query); }, 300); // In your input handler searchInput.addEventListener('input', (e) => { debouncedSearch(e.target.value); });
Lazy Loading: Load options only when needed, especially for large datasets.
// Load options only when dropdown is opened 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 appropriatetop
/left
valuesConsider using a positioning library like Popper.js for complex positioning
Dropdown menu gets cut off by container boundaries:
Use
overflow: visible
on parent containersConsider using a portal to render the dropdown menu at the document root
Implement dynamic positioning based on available space
Z-index issues:
Ensure the dropdown menu has a higher z-index than surrounding elements
Check for stacking contexts created by parent elements
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
// Correct implementation
document.addEventListener('click', (event) => {
if (!dropdownElement.contains(event.target)) {
closeDropdown();
}
});
// Common mistake (stopping propagation)
dropdownToggle.addEventListener('click', (event) => {
event.stopPropagation(); // This prevents the document click handler from working
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 changesTest with actual screen readers
Focus gets lost when dropdown closes:
Ensure focus is returned to the toggle button when the dropdown closes
Implement proper focus trapping within the dropdown when open
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:
Form Selection
// components/form-with-dropdown.tsx
"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)
// Process form submission
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
// components/data-table-with-filters.tsx
"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)
// Apply filters when search query or category changes
useEffect(() => {
setIsLoading(true)
// Simulate API delay
const timer = setTimeout(() => {
let filtered = [...products]
// Apply search filter
if (searchQuery) {
filtered = filtered.filter(product =>
product.name.toLowerCase().includes(searchQuery.toLowerCase())
)
}
// Apply category filter
if (categoryFilter !== "all") {
filtered = filtered.filter(product =>
product.category === categoryFilter
)
}
setFilteredProducts(filtered)
setIsLoading(false)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, categoryFilter, products])
// Reset all filters
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>
)
}
Navigation Menu
// components/navigation-dropdown.tsx
"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">
{/* Mobile Navigation */}
<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>
{/* Desktop Navigation */}
<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>
))}
{/* User Menu Dropdown */}
<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
# If using npm
npm install @your-org/dropdown
# If using yarn
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:
/* Main container */
.dropdown-container {
/* Your styles */
}
/* Dropdown button */
.dropdown-toggle {
/* Your styles */
}
/* Dropdown menu */
.dropdown-menu {
/* Your styles */
}
/* Dropdown option */
.dropdown-option {
/* Your styles */
}
/* Selected option */
.dropdown-option.selected {
/* Your styles */
}
/* Disabled state */
.dropdown-container.disabled {
/* Your styles */
}
/* Error state */
.dropdown-container.has-error {
/* Your styles */
}
Browser Support
The Dropdown component supports all modern browsers:
Chrome (latest)
Firefox (latest)
Safari (latest)
Edge (latest)
Internet Explorer 11 (with polyfills)
License
MIT
Subscribe to my newsletter
Read articles from Valentina Alexander directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Valentina Alexander
Valentina Alexander
Cofounder of DeDevs, which serves as a hub for cutting-edge development, knowledge sharing, and collaboration on groundbreaking projects. Our community specifically focuses on the intersection of blockchain and AI technologies, providing a unique space where experts from both fields can come together to innovate and learn from each other.