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:

  1. Update the selected value

  2. Update the display text in the toggle button

  3. Close the dropdown

  4. 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:

  1. Text color vs background color should have a contrast ratio of at least 4.5:1

  2. Focus indicators should be clearly visible

  3. Selected state should be distinguishable

Testing Accessibility

Test your dropdown component with various assistive technologies:

  1. Screen readers: Test with popular screen readers like NVDA, JAWS, or VoiceOver

  2. Keyboard-only: Navigate through the dropdown using only keyboard

  3. High contrast mode: Ensure the dropdown is usable in high contrast mode

  4. 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

  1. 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';
    
     // 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>
    
  2. 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);
     });
    
  3. 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

  1. Provide Clear Feedback: Always indicate the current selection and provide visual feedback for interactions.

  2. Maintain Context: Keep the dropdown in view when scrolling, especially for long forms.

  3. Appropriate Sizing: Make dropdown targets large enough for comfortable interaction (at least 44x44 pixels for touch targets).

  4. Predictable Behavior: Ensure consistent behavior across your application.

  5. Default Values: Provide sensible defaults when appropriate.

  6. Error Handling: Display clear error messages when operations fail.

  7. Loading States: Show loading indicators for asynchronous operations.

Common Pitfalls to Avoid

  1. Overloading Dropdowns: Don't put too many options in a single dropdown. Consider alternative UI patterns for very large sets.

  2. Ignoring Mobile Users: Ensure dropdowns are touch-friendly and work well on small screens.

  3. Poor Keyboard Support: Always implement proper keyboard navigation.

  4. Neglecting Accessibility: Make sure your dropdown is accessible to all users.

  5. Inconsistent Styling: Maintain consistent styling with your application's design system.

  6. Slow Performance: Optimize for performance, especially with large datasets.

  7. Complex Nesting: Avoid deeply nested dropdown menus as they can be difficult to navigate.

8. Troubleshooting

Styling Issues

  1. 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

  2. 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

  3. 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

  1. 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();
    });
  1. 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

  2. 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

  1. 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

  2. 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

  3. 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

PropTypeDefaultDescription
optionsArray<{ label: string, value: string }>RequiredArray of options to display in the dropdown
valuestringundefinedThe currently selected value
onSelect(value: string) => voidRequiredCallback function called when an option is selected
placeholderstring'Select an option'Text to display when no option is selected
disabledbooleanfalseWhether the dropdown is disabled
errorstringundefinedError message to display
classNamestringundefinedAdditional CSS class for the dropdown container
menuClassNamestringundefinedAdditional CSS class for the dropdown menu
buttonClassNamestringundefinedAdditional CSS class for the dropdown button

Methods

MethodDescription
open()Opens the dropdown menu
close()Closes the dropdown menu
toggle()Toggles the dropdown menu
setValue(value: string)Programmatically sets the selected value

Events

EventDescription
onOpenFired when the dropdown menu opens
onCloseFired when the dropdown menu closes
onSelectFired when an option is selected
onFocusFired when the dropdown receives focus
onBlurFired when the dropdown loses focus

Keyboard Navigation

KeyAction
Enter / SpaceOpens the dropdown menu or selects the focused option
EscapeCloses the dropdown menu
Arrow DownMoves focus to the next option
Arrow UpMoves focus to the previous option
HomeMoves focus to the first option
EndMoves focus to the last option
TabMoves 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

0
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.