Skip to content
RTL Support: Full
Accessibility: Full
Translations: Not Needed

Dropdown

A highly customizable and extensible dropdown component that supports flat lists, nested tree navigation, search, selection states, and rich option rendering.

Basic Dropdown

The basic dropdown component provides a way to display a list of selectable options in a popup menu. It expects the trigger element to be the default slot.

Reactivity Note

options and nested children are tracked shallowly. When your data changes, replace the array reference (for example options.value = [...nextOptions]) instead of mutating in place so the menu state, search index, and keyboard navigation refresh correctly.

Here's a simple dropdown with click trigger and basic options:

vue
<template>
  <HLDropdown
    id="basic-dropdown"
    trigger="click"
    placement="bottom"
    :options="simpleOptions"
    :show-search="false"
    width="200"
    @select="handleSelect"
  >
    <HLButton size="sm">Basic Dropdown</HLButton>
  </HLDropdown>
</template>
ts
// Define your options - each must have a unique key and label
const simpleOptions = [
  {
    key: 'edit',
    label: 'Edit Document',
  },
  {
    key: 'share',
    label: 'Share with Team',
  },
  {
    key: 'download',
    label: 'Download as PDF',
  },
  {
    key: 'duplicate',
    label: 'Make a Copy',
  },
  {
    key: 'archive',
    label: 'Archive Document',
  },
  {
    key: 'delete',
    label: 'Delete Document',
  },
]

1. Trigger Types

Choose how users activate the dropdown:

vue
<!-- Click Trigger (Default) -->
<HLDropdown trigger="click" :options="simpleOptions" :show-search="false">
  <HLButton>Click Me</HLButton>
</HLDropdown>

<!-- Hover Trigger -->
<HLDropdown trigger="hover" :options="simpleOptions" :show-search="false">
  <HLButton>Hover Me</HLButton>
</HLDropdown>
ts
// Define your options - each must have a unique key and label
const simpleOptions = [
  {
    key: 'edit',
    label: 'Edit Document',
  },
  {
    key: 'share',
    label: 'Share with Team',
  },
  {
    key: 'download',
    label: 'Download as PDF',
  },
  {
    key: 'duplicate',
    label: 'Make a Copy',
  },
  {
    key: 'archive',
    label: 'Archive Document',
  },
  {
    key: 'delete',
    label: 'Delete Document',
  },
]

2. Placement Options

Position your dropdown relative to the trigger element in any of the following directions: top-start, top, top-end, right-start, right, right-end, bottom-start, bottom, bottom-end, left-start, left, left-end

vue
<!-- Different placement examples -->
<HLDropdown id="placement-demo-1" placement="top-start" :options="simpleOptions" :show-search="false" width="200">
    <HLButton size="sm">Opens Top Start</HLButton>
  </HLDropdown>

<HLDropdown id="placement-demo-2" placement="bottom-end" :options="simpleOptions" :show-search="false" width="200">
<HLButton size="sm">Opens Bottom End</HLButton>
</HLDropdown>

<HLDropdown id="placement-demo-3" placement="right" :options="simpleOptions" :show-search="false" width="200">
<HLButton size="sm">Opens Right</HLButton>
</HLDropdown>
ts
// Define your options - each must have a unique key and label
const simpleOptions = [
  {
    key: 'edit',
    label: 'Edit Document',
  },
  {
    key: 'share',
    label: 'Share with Team',
  },
  {
    key: 'download',
    label: 'Download as PDF',
  },
  {
    key: 'duplicate',
    label: 'Make a Copy',
  },
  {
    key: 'archive',
    label: 'Archive Document',
  },
  {
    key: 'delete',
    label: 'Delete Document',
  },
]

3. Width Control

Adapt the dropdown width to your content:

vue
<!-- Fixed width -->
<HLDropdown id="width-demo-1" :width="200" :options="simpleOptions" :show-search="false">
    <HLButton size="sm">Fixed 200px Width</HLButton>
  </HLDropdown>

<!-- Auto width (matches trigger width) -->
<HLDropdown id="width-demo-2" width="auto" :options="simpleOptions" :show-search="false">
<HLButton size="sm" class="w-[200px]">Auto Width</HLButton>
</HLDropdown>
ts
// Define your options - each must have a unique key and label
const simpleOptions = [
  {
    key: 'edit',
    label: 'Edit Document',
  },
  {
    key: 'share',
    label: 'Share with Team',
  },
  {
    key: 'download',
    label: 'Download as PDF',
  },
  {
    key: 'duplicate',
    label: 'Make a Copy',
  },
  {
    key: 'archive',
    label: 'Archive Document',
  },
  {
    key: 'delete',
    label: 'Delete Document',
  },
]

Nested Options

The dropdown component supports nested options, which can be displayed in either a hierarchical cascade or a tree structure.

1. Cascading Dropdown

A cascading dropdown displays options in a linear sequence, where each option's children are displayed as a new dropdown.

vue
<HLDropdown id="nested-dropdown" trigger="click" placement="bottom" :options="nestedOptions" width="200">
    <HLButton size="sm">Nested Dropdown</HLButton>
  </HLDropdown>
ts
const nestedOptions = [
  {
    key: 'produce',
    label: 'Fresh Produce',
    children: [
      {
        key: 'fruits',
        label: 'Fruits & Berries',
        children: [
          {
            key: 'tropical',
            label: 'Tropical Fruits',
            children: [
              { key: 'mango', label: 'Mango', description: 'Sweet and juicy' },
              { key: 'pineapple', label: 'Pineapple', description: 'Tangy and tropical' },
              { key: 'papaya', label: 'Papaya', description: 'Soft and sweet' },
            ],
          },
          {
            key: 'berries',
            label: 'Fresh Berries',
            children: [
              { key: 'strawberry', label: 'Strawberry', description: 'Red and fragrant' },
              { key: 'blueberry', label: 'Blueberry', description: 'Small and antioxidant-rich' },
              { key: 'raspberry', label: 'Raspberry', description: 'Tart and delicate' },
            ],
          },
        ],
      },
      {
        key: 'vegetables',
        label: 'Vegetables',
        children: [
          {
            key: 'leafy',
            label: 'Leafy Greens',
            children: [
              { key: 'spinach', label: 'Spinach', description: 'Dark and nutritious' },
              { key: 'kale', label: 'Kale', description: 'Crispy and healthy' },
              { key: 'lettuce', label: 'Lettuce', description: 'Fresh and crisp' },
            ],
          },
          {
            key: 'root',
            label: 'Root Vegetables',
            children: [
              { key: 'carrot', label: 'Carrot', description: 'Orange and crunchy' },
              { key: 'potato', label: 'Potato', description: 'Starchy and versatile' },
              { key: 'beet', label: 'Beet', description: 'Deep red and earthy' },
            ],
          },
        ],
      },
    ],
  },
  {
    key: 'dairy',
    label: 'Dairy & Eggs',
    children: [
      {
        key: 'milk_products',
        label: 'Milk Products',
        children: [
          {
            key: 'fresh_milk',
            label: 'Fresh Milk',
            children: [
              { key: 'whole_milk', label: 'Whole Milk', description: 'Full fat and creamy' },
              { key: 'reduced_fat', label: '2% Milk', description: 'Reduced fat option' },
              { key: 'skim_milk', label: 'Skim Milk', description: 'Fat-free option' },
            ],
          },
          {
            key: 'yogurt',
            label: 'Yogurt',
            children: [
              { key: 'greek', label: 'Greek Yogurt', description: 'Thick and protein-rich' },
              { key: 'regular', label: 'Regular Yogurt', description: 'Smooth and creamy' },
              { key: 'probiotic', label: 'Probiotic Yogurt', description: 'With live cultures' },
            ],
          },
        ],
      },
      {
        key: 'cheese',
        label: 'Cheese',
        children: [
          {
            key: 'hard_cheese',
            label: 'Hard Cheese',
            children: [
              { key: 'cheddar', label: 'Cheddar', description: 'Sharp and aged' },
              { key: 'parmesan', label: 'Parmesan', description: 'Granular and salty' },
              { key: 'gouda', label: 'Gouda', description: 'Rich and smooth' },
            ],
          },
          {
            key: 'soft_cheese',
            label: 'Soft Cheese',
            children: [
              { key: 'brie', label: 'Brie', description: 'Creamy and mild' },
              { key: 'mozzarella', label: 'Mozzarella', description: 'Fresh and milky' },
              { key: 'camembert', label: 'Camembert', description: 'Rich and buttery' },
            ],
          },
        ],
      },
    ],
  },
]

2. Dropdown Tree

The same options can be displayed in a nested tree structure, which enables hierarchical navigation:

html
<template>
  <HLDropdown id="tree-dropdown" trigger="click" placement="bottom" :options="nestedOptions" tree-mode show-search :width="280">
    <HLButton size="sm">Dropdown Tree</HLButton>
  </HLDropdown>
</template>
ts
const nestedOptions = [
  {
    key: 'produce',
    label: 'Fresh Produce',
    children: [
      {
        key: 'fruits',
        label: 'Fruits & Berries',
        children: [
          {
            key: 'tropical',
            label: 'Tropical Fruits',
            children: [
              { key: 'mango', label: 'Mango', description: 'Sweet and juicy' },
              { key: 'pineapple', label: 'Pineapple', description: 'Tangy and tropical' },
              { key: 'papaya', label: 'Papaya', description: 'Soft and sweet' },
            ],
          },
          {
            key: 'berries',
            label: 'Fresh Berries',
            children: [
              { key: 'strawberry', label: 'Strawberry', description: 'Red and fragrant' },
              { key: 'blueberry', label: 'Blueberry', description: 'Small and antioxidant-rich' },
              { key: 'raspberry', label: 'Raspberry', description: 'Tart and delicate' },
            ],
          },
        ],
      },
      {
        key: 'vegetables',
        label: 'Vegetables',
        children: [
          {
            key: 'leafy',
            label: 'Leafy Greens',
            children: [
              { key: 'spinach', label: 'Spinach', description: 'Dark and nutritious' },
              { key: 'kale', label: 'Kale', description: 'Crispy and healthy' },
              { key: 'lettuce', label: 'Lettuce', description: 'Fresh and crisp' },
            ],
          },
          {
            key: 'root',
            label: 'Root Vegetables',
            children: [
              { key: 'carrot', label: 'Carrot', description: 'Orange and crunchy' },
              { key: 'potato', label: 'Potato', description: 'Starchy and versatile' },
              { key: 'beet', label: 'Beet', description: 'Deep red and earthy' },
            ],
          },
        ],
      },
    ],
  },
  {
    key: 'dairy',
    label: 'Dairy & Eggs',
    children: [
      {
        key: 'milk_products',
        label: 'Milk Products',
        children: [
          {
            key: 'fresh_milk',
            label: 'Fresh Milk',
            children: [
              { key: 'whole_milk', label: 'Whole Milk', description: 'Full fat and creamy' },
              { key: 'reduced_fat', label: '2% Milk', description: 'Reduced fat option' },
              { key: 'skim_milk', label: 'Skim Milk', description: 'Fat-free option' },
            ],
          },
          {
            key: 'yogurt',
            label: 'Yogurt',
            children: [
              { key: 'greek', label: 'Greek Yogurt', description: 'Thick and protein-rich' },
              { key: 'regular', label: 'Regular Yogurt', description: 'Smooth and creamy' },
              { key: 'probiotic', label: 'Probiotic Yogurt', description: 'With live cultures' },
            ],
          },
        ],
      },
      {
        key: 'cheese',
        label: 'Cheese',
        children: [
          {
            key: 'hard_cheese',
            label: 'Hard Cheese',
            children: [
              { key: 'cheddar', label: 'Cheddar', description: 'Sharp and aged' },
              { key: 'parmesan', label: 'Parmesan', description: 'Granular and salty' },
              { key: 'gouda', label: 'Gouda', description: 'Rich and smooth' },
            ],
          },
          {
            key: 'soft_cheese',
            label: 'Soft Cheese',
            children: [
              { key: 'brie', label: 'Brie', description: 'Creamy and mild' },
              { key: 'mozzarella', label: 'Mozzarella', description: 'Fresh and milky' },
              { key: 'camembert', label: 'Camembert', description: 'Rich and buttery' },
            ],
          },
        ],
      },
    ],
  },
]

The dropdown component includes a built-in search functionality that is enabled by default (show-search prop defaults to true). The search feature:

  • Filters through leaf nodes (options without children) in both flat and nested structures
  • Matches case-insensitive text against the option's label property
  • Displays the full path for nested options (e.g., "Parent / Child")
  • Can be disabled by setting show-search to false

For custom search (e.g. server-side or debounced filtering), use the on-search prop and/or control the query with v-model:search-value while show-search stays true.

Vue Template
html
<HLDropdown id="basic-dropdown" trigger="click" placement="bottom" :options="longOptions" width="200" show-search>
  <HLButton size="sm">Basic Dropdown with Search</HLButton>
</HLDropdown>

The dropdown component allows you to implement custom search functionality by handling the @search event. This is useful when you need to perform server-side filtering or apply complex search logic.

html
<template>
  <HLDropdown
    id="custom-search-dropdown"
    trigger="click"
    placement="bottom"
    :options="filteredOptions"
    show-search
    :width="280"
    @search="handleSearch"
  >
    <HLButton size="sm">Custom Search</HLButton>
  </HLDropdown>
</template>
ts
const searchQuery = ref('')
const isLoading = ref(false)

const allOptions = [
  {
    key: 'fruits',
    label: 'Fruits',
    children: [
      { key: 'apple', label: 'Apple', description: 'Red and sweet' },
      { key: 'banana', label: 'Banana', description: 'Yellow and creamy' },
      { key: 'orange', label: 'Orange', description: 'Citrus fruit' },
    ],
  },
  {
    key: 'vegetables',
    label: 'Vegetables',
    children: [
      { key: 'carrot', label: 'Carrot', description: 'Orange and crunchy' },
      { key: 'broccoli', label: 'Broccoli', description: 'Green and healthy' },
      { key: 'potato', label: 'Potato', description: 'Starchy vegetable' },
    ],
  },
]

const filteredOptions = computed(() => {
  if (!searchQuery.value) return allOptions

  const searchLower = searchQuery.value.toLowerCase()

  return allOptions
    .map(group => {
      const matchingChildren = group.children?.filter(
        item => item.label.toLowerCase().includes(searchLower) || item.description?.toLowerCase().includes(searchLower)
      )

      if (!matchingChildren?.length) return null

      return {
        ...group,
        children: matchingChildren,
      }
    })
    .filter(Boolean)
})

let searchTimeout
const handleSearch = value => {
  clearTimeout(searchTimeout)
  isLoading.value = true

  searchTimeout = setTimeout(() => {
    searchQuery.value = value
    isLoading.value = false
  }, 300)
}

Height Limitation

The maxHeight prop can be used to limit the height of the dropdown menu, making it scrollable when content exceeds the maximum height.

Vue Template
html
<HLDropdown
  id="tree-dropdown"
  trigger="click"
  placement="bottom"
  :options="longOptions"
  tree-mode
  show-search
  max-height="200px"
  :width="280"
>
  <HLButton size="sm">Dropdown Tree with Max Height</HLButton>
</HLDropdown>

In cascade mode, you can specify a unique maxHeight for each submenu using the childrenMaxHeight property on parent options. This allows fine-grained control over scrolling behavior in nested menus.

  • childrenMaxHeight is set on parent options that have children
  • Each submenu can have its own unique max height
  • If childrenMaxHeight is not specified, the submenu falls back to the parent dropdown's maxHeight prop
  • This is particularly useful for deeply nested menus where different levels need different scroll heights
html
<template>
  <HLDropdown
    id="cascade-max-height"
    trigger="hover"
    placement="bottom-start"
    :options="cascadeMaxHeightOptions"
    max-height="300px"
    :show-search="false"
    width="200"
  >
    <HLButton size="sm">Cascade with Custom Heights</HLButton>
  </HLDropdown>
</template>
ts
const cascadeMaxHeightOptions = [
  {
    key: 'parent1',
    label: 'Parent 1 - Scrollable Submenu',
    childrenMaxHeight: '200px', // Custom max height for this submenu
    children: Array.from({ length: 20 }, (_, i) => ({
      key: `parent1-child-${i + 1}`,
      label: `Child ${i + 1}`,
    })),
  },
  {
    key: 'parent2',
    label: 'Parent 2 - Multi-Level Nesting',
    childrenMaxHeight: '150px', // First level submenu max height
    children: [
      {
        key: 'parent2-child1',
        label: 'Level 2 - Scrollable',
        childrenMaxHeight: '200px', // Second level submenu max height
        children: Array.from({ length: 25 }, (_, i) => ({
          key: `parent2-child1-grandchild-${i + 1}`,
          label: `Grandchild ${i + 1}`,
        })),
      },
      {
        key: 'parent2-child2',
        label: 'Level 2 - Different Height',
        childrenMaxHeight: '120px', // Different max height for this submenu
        children: Array.from({ length: 15 }, (_, i) => ({
          key: `parent2-child2-grandchild-${i + 1}`,
          label: `Grandchild ${i + 1}`,
        })),
      },
    ],
  },
]

Option Types

The dropdown component supports various option types, each designed for specific use cases. Below is a concise overview of each type with examples:

  1. Default Text Option - Simple text options with a key and a label.

    ts
    { key: 'default', label: 'Default Option' }
  2. Header - Used to group options together.

    ts
    { key: 'header1', label: 'Group 1', type: 'header' }
  3. Divider - Used to separate options.

    ts
    { key: 'divider1', type: 'divider' }
  4. Avatar - Displays an image.

    ts
    { key: 'avatar1', label: 'User Profile', type: 'avatar', src: 'https://ui-avatars.com/api/?name=John+Doe' }
  5. Icon - Displays an icon. You can also set iconPlacement to place the icon on either side of the label. Note that if you have children, the icon will be placed on the left side by default to accomodate the chevron icon for the children.

    ts
    { key: 'icon1', label: 'Verified Account', type: 'icon', icon: CheckVerified01Icon, iconPlacement: 'left' }
  6. Description with Icon - Displays a description with an icon

    ts
    { key: 'desc1', label: 'Share Post', description: 'Share to social media channels', descriptionIcon: CheckVerified01Icon }
  7. Info Text - Displays additional text to the right of the label

    ts
    { key: 'info1', label: 'Messages', infoText: '5 unread' }
  8. Title Right Slot - Displays custom content on the right side

    ts
    { key: 'slot1', label: 'Performance', titleRightSlot: () => h(HLTag, { size: 'xs', round: true, variant: 'success' }, { default: () => '↑ 10%' }) }
  9. Disabled - Disables an option

    ts
    { key: 'disabled1', label: 'Unavailable Feature', disabled: true }
  10. Search: Displays a search input

    ts
    { key: '__search__', label: 'Search', type: 'search', searchPlaceholder: 'Search options...' }
vue
<template>
  <HLDropdown :options="demoOptions" showSearch treeMode showSelectedMark :closeOnSelect="false" @select="handleDemoSelect">
    <HLButton>{{ demoSelectedValue }}</HLButton>
  </HLDropdown>
</template>
ts

<script setup>
import { CheckVerified01Icon } from '@gohighlevel/ghl-icons/24/outline'
import { h } from 'vue'
import { HLTag } from '@gohighlevel/highrise'

const options = [
  // Default option
  {
    key: 'default',
    label: 'Default Option',
  },

  // Header option
  {
    key: 'header1',
    label: 'Group 1',
    type: 'header',
  },

  // Divider
  {
    key: 'divider1',
    type: 'divider',
  },

  // Avatar option
  {
    key: 'avatar1',
    label: 'User Profile',
    type: 'avatar',
    src: 'https://example.com/avatar.jpg',
  },

  // Icon option with left placement
  {
    key: 'icon1',
    label: 'Verified Account',
    type: 'icon',
    icon: CheckVerified01Icon,
    iconPlacement: 'left',
  },

  // Custom render option
  {
    key: 'render1',
    type: 'render',
    render: () => h('div', { class: 'custom-render' }, 'Custom Rendered Content'),
  },

  // Option with description and icon
  {
    key: 'desc1',
    label: 'Share Post',
    description: 'Share to social media channels',
    descriptionIcon: CheckVerified01Icon,
  },

  // Option with info text
  {
    key: 'info1',
    label: 'Messages',
    infoText: '5 unread',
  },

  // Option with title right slot
  {
    key: 'slot1',
    label: 'Performance',
    titleRightSlot: () => h(HLTag, { size: 'xs', round: true, variant: 'success' }, { default: () => '↑ 10%' }),
  },

  // Disabled option
  {
    key: 'disabled1',
    label: 'Unavailable Feature',
    disabled: true,
  },

  // Nested options (children)
  {
    key: 'parent1',
    label: 'Settings',
    children: [
      {
        key: 'child1',
        label: 'General',
      },
      {
        key: 'child2',
        label: 'Security',
      },
    ],
  },
]
</script>

Infinite Scroll

The dropdown component supports infinite scrolling through the @scroll event. This is useful for loading large datasets progressively as the user scrolls. The scroll event is fired when maxHeight is set, making the dropdown scrollable.

html
<template>
  <HLDropdown 
    :options="infiniteScrollOptions" 
    @scroll="handleInfiniteScroll" 
    max-height="300px"
    :show-search="false"
    :close-on-select="false"
  >
    <template #loader>
      <div v-if="infiniteLoading || hasMore" style="padding: 8px; text-align: center;">
        <HLSpin v-if="infiniteLoading" size="sm" />
      </div>
    </template>
    <HLButton size="sm">Infinite Scroll ({{ infiniteScrollOptions.length }} items)</HLButton>
  </HLDropdown>
</template>
ts
<script setup>
import { HLDropdown, HLButton, HLSpin } from '@platform-ui/highrise'
import { ref, onMounted } from 'vue'

const infiniteScrollOptions = ref([])
const infiniteLoading = ref(false)
const infinitePage = ref(1)
const hasMore = ref(true)
const itemsPerPage = 20

const generateInfiniteItems = (start, count) => {
  return Array.from({ length: count }, (_, index) => ({
    key: `item-${start + index}`,
    label: `Item ${start + index}`,
  }))
}

const loadMoreInfiniteItems = async () => {
  if (infiniteLoading.value || !hasMore.value) return
  
  infiniteLoading.value = true
  try {
    await new Promise(resolve => setTimeout(resolve, 800))
    const newItems = generateInfiniteItems((infinitePage.value - 1) * itemsPerPage + 1, itemsPerPage)
    infiniteScrollOptions.value = [...infiniteScrollOptions.value, ...newItems]
    infinitePage.value += 1
    hasMore.value = infinitePage.value < 6 // Limit to 5 pages (100 items)
  } finally {
    infiniteLoading.value = false
  }
}

onMounted(() => {
  loadMoreInfiniteItems()
})

const handleInfiniteScroll = async (event) => {
  const { scrollTop, scrollHeight, clientHeight } = event.target
  if (scrollHeight - scrollTop - clientHeight < 50) {
    await loadMoreInfiniteItems()
  }
}

</script>
  • The @scroll event fires when the dropdown is scrolled (requires maxHeight to be set)
  • Use the loader slot to display a loading indicator while fetching more items
  • The scroll event provides access to the scroll container, allowing you to detect when the user reaches the bottom
  • Perfect for implementing pagination or async loading of large option lists

Custom Render

The dropdown component supports custom rendering of options using the render type. This is useful for creating highly customized option displays:

WARNING

The @select event is not supported in the render type. You might want to add click handlers to the custom rendered content to handle the selection.

html
<template>
  <HLDropdown id="custom-render-dropdown" trigger="click" placement="bottom" :options="options">
    <HLButton size="sm">Custom Render</HLButton>
  </HLDropdown>
</template>
ts
const options = [

      {
        key: 'complex-custom-content',
        label: 'Complex Custom Content',
        type: 'render',
        render: () => h('div', { class: 'flex flex-col gap-2 p-2' }, [
          h('div', { class: 'flex items-center gap-2' }, [
            h('img', {
              src: 'https://ui-avatars.com/api/?name=John+Doe',
              class: 'w-8 h-8 rounded-full',
              alt: 'Avatar',
            }),
            h('div', { class: 'text-sm font-medium' }, 'John Doe'),
          ]),
          h('div', { class: 'text-xs text-gray-500' }, 'This is a more complex custom rendered option'),
          h('div', { class: 'mt-2 flex items-center gap-2' }, [
            h('div', { class: 'w-2 h-2 rounded-full bg-green-500' }),
            h('span', { class: 'text-xs' }, 'Active'),
            h(HLTag, { size: 'xs', round: true, variant: 'info' }, { default: () => 'New' }),
          ]),
        ])
      },
      {
        key: 'notification',
        label: 'Notification Item',
        type: 'render',
        render: () => h('div', { class: 'flex items-center justify-between p-2 border-b border-gray-100' }, [
          h('div', { class: 'flex items-center gap-3' }, [
            h('div', { class: 'w-2 h-2 rounded-full bg-error-500' }),
            h('div', { class: 'flex flex-col' }, [
              h('div', { class: 'text-sm font-medium' }, 'System Alert'),
              h('div', { class: 'text-xs text-gray-500' }, '2 minutes ago')
            ])
          ]),
          h(HLTag, { size: 'xs', variant: 'error' }, { default: () => 'Urgent' })
        ])
      },
      {
        key: 'user-stats',
        label: 'User Statistics',
        type: 'render',
        render: () => h('div', { class: 'p-2 flex flex-col gap-2' }, [
          h('div', { class: 'flex items-center justify-between' }, [
            h('div', { class: 'text-sm font-medium' }, 'Monthly Stats'),
            h(HLTag, { size: 'xs', variant: 'success', round: true }, { default: () => '↑ 23%' })
          ]),
          h('div', { class: 'flex gap-4 mt-1' }, [
            h('div', { class: 'flex flex-col items-center' }, [
              h('span', { class: 'text-xs font-medium' }, '1.2k'),
              h('span', { class: 'text-xs text-gray-500' }, 'Views')
            ]),
            h('div', { class: 'flex flex-col items-center' }, [
              h('span', { class: 'text-xs font-medium' }, '8.4k'),
              h('span', { class: 'text-xs text-gray-500' }, 'Clicks')
            ]),
            h('div', { class: 'flex flex-col items-center' }, [
              h('span', { class: 'text-xs font-medium' }, '98%'),
              h('span', { class: 'text-xs text-gray-500' }, 'Rating')
            ])
          ])
        ])
      },
      {
        key: 'team-member',
        label: 'Team Member',
        type: 'render',
        render: () => h('div', { class: 'p-2 flex items-center justify-between' }, [
          h('div', { class: 'flex items-center gap-3' }, [
            h('img', {
              src: 'https://ui-avatars.com/api/?name=Sarah+Wilson',
              class: 'w-8 h-8 rounded-full',
              alt: 'Sarah Wilson'
            }),
            h('div', { class: 'flex flex-col' }, [
              h('div', { class: 'text-sm font-medium' }, 'Sarah Wilson'),
              h('div', { class: 'text-xs text-gray-500 flex items-center gap-1' }, [
                h('div', { class: 'w-1.5 h-1.5 rounded-full bg-success-500' }),
                'Online'
              ])
            ])
          ]),
          h('div', { class: 'flex gap-2' }, [
            h(HLTag, { size: 'xs', variant: 'warning' }, { default: () => 'Lead' })
          ])
        ])
      }
    ]"
]

Multiple Selection

The dropdown component supports multiple selection mode, which allows users to select multiple options. You can also control whether the dropdown should close after selection by setting the closeOnSelect prop to false. The showSelectedMark prop can be used to show a checkmark next to the selected option.

html
<template>
  <HLDropdown
    id="multiple-dropdown"
    trigger="click"
    placement="bottom"
    :options="options"
    multiple
    :closeOnSelect="false"
    show-selected-mark
    :width="280"
  >
    <HLButton size="sm">Multiple Selection</HLButton>
  </HLDropdown>
</template>

<script setup>
  const options = [
    {
      key: 'fruits',
      label: 'Fruits',
      children: [
        { key: 'apple', label: 'Apple' },
        { key: 'banana', label: 'Banana' },
        { key: 'orange', label: 'Orange' },
      ],
    },
    {
      key: 'vegetables',
      label: 'Vegetables',
      children: [
        { key: 'carrot', label: 'Carrot' },
        { key: 'broccoli', label: 'Broccoli' },
        { key: 'potato', label: 'Potato' },
      ],
    },
  ]
</script>

Disabled Options

You can disable individual options by setting the disabled key to true on the dropdown option. Disabled options cannot be selected and do not emit the select event when clicked and display a not-allowed cursor on hover.

html
<template>
  <HLDropdown id="disabled-dropdown" trigger="click" placement="bottom" :options="disabledOptions" :width="280">
    <HLButton size="sm">Disabled Options</HLButton>
  </HLDropdown>
</template>
ts
const disabledOptions = [
  {
    key: 'option1',
    label: 'Enabled Option',
    description: 'This option is selectable',
  },
  {
    key: 'option2',
    label: 'Disabled Option',
    description: 'This option cannot be selected',
    disabled: true,
  },
  {
    key: 'group',
    label: 'Mixed Group',
    children: [
      { key: 'enabled', label: 'Enabled Child' },
      { key: 'disabled-child', label: 'Disabled Child', disabled: true },
    ],
  },
]

Disable Selection Highlight

Suppress the selected-option highlight by setting the highlightSelection prop to false.

html
<template>
  <HLDropdown
    id="hidden-selection-highlight-dropdown"
    trigger="click"
    placement="bottom"
    :options="simpleOptions"
    :highlightSelection="false"
  >
    <HLButton size="sm">Hidden Selection Highlight</HLButton>
  </HLDropdown>
</template>

Search Highlight

The showSearchHighlight prop enables highlighting of search matches in option labels. When enabled, matching text in option labels will be visually highlighted.

html
<template>
  <HLDropdown
    id="search-highlight-dropdown"
    trigger="click"
    placement="bottom"
    :options="options"
    show-search
    show-search-highlight
    width="200"
  >
    <HLButton size="sm">Search with Highlight</HLButton>
  </HLDropdown>
</template>

Disabled Dropdown

You can also disable the entire dropdown by setting the disabled prop on the dropdown component.

WARNING

A disabled dropdown cannot be opened, but the trigger itself remains active. To disable the trigger, it must be explicitly set as disabled.

html
<template>
  <HLDropdown id="disabled-entire-dropdown" trigger="click" placement="bottom" :options="simpleOptions" disabled :width="200">
    <HLButton size="sm">Disabled Dropdown</HLButton>
  </HLDropdown>
</template>

Accessibility

  • Trigger: Pass id (or rely on the auto-generated id) so the component can assign stable ids: {id}-trigger on the trigger wrapper and {id}-menu on the menu panel. Add aria-haspopup="menu" and :aria-expanded directly on your trigger element (e.g., HLButton) inside the default slot. The menu root uses aria-labelledby pointing at the trigger id.
  • Menu panel: The list surface is exposed as role="menu" with aria-orientation="vertical", tabindex="-1" and aria-labelledby referencing the trigger.
  • Options: Rows use appropriate roles (menuitem, menuitemcheckbox / menuitemradio when selection marks apply), tabindex on focusable rows, and aria-disabled when an option is disabled. Disabled options are not interactive and do not open cascade submenus.
  • Pointer and keyboard parity: @mouseenter and @mouseleave fire when the pointer enters or leaves the menu panel, and also when focus moves into or out of the panel from outside (for example when tabbing from the trigger into the menu). Moving focus between items inside the menu does not re-fire these events. Event payloads are typed as Event (typically MouseEvent or FocusEvent).
  • You can still point to helper or validation text with aria-describedby on the trigger via triggerAttrs, and surface async loading with aria-busy when needed.

Props

PropTypeDefaultDescription
idstringAuto (hr-dropdown-*)Unique identifier for the dropdown. Omitted values receive a stable auto-generated id for accessibility and testing.
optionsDropdownOption[][]Array of options to display in the dropdown menu. See Option Properties for details.
trigger'click' | 'hover' | 'manual''click'How the menu opens. manual opens only via v-model:show / show (no pointer trigger). The focus trigger is not supported.
placement'top-start' | 'top' | 'top-end' | 'right-start' | 'right' | 'right-end' | 'bottom-start' | 'bottom' | 'bottom-end' | 'left-start' | 'left' | 'left-end''bottom'Position of the dropdown menu relative to the trigger element.
widthnumber | 'auto'182Width of the dropdown menu. Use a number for fixed pixels, or 'auto' to match the trigger width.
showboolean | undefinedundefinedControlled visibility state. Use with v-model:show for two-way binding.
showArrowbooleantrueShows a pointing arrow from the menu to the trigger.
showSelectedMarkbooleanfalseShows a checkmark next to the selected option.
treeModebooleanfalseEnables hierarchical navigation for nested options.
showSearchbooleantrueShows a search input at the top of the dropdown.
multiplebooleanfalseEnables multiple selection mode.
closeOnSelectbooleantrueControls whether the dropdown should close after an option is selected. Selected Mark is not shown if this is set to false.
resetTreeOnChangebooleanfalseControls whether the dropdown tree should reset the tree state when the dropdown is closed.
disabledbooleanfalseDisables the entire dropdown, preventing it from being opened.
maxHeightstring | undefinedundefinedMaximum height of the dropdown menu. Makes the dropdown scrollable when content exceeds this height.
showSearchHighlightbooleanfalseHighlights matching text in option labels when searching.
triggerAttrsHTMLAttributes{}Additional attributes to pass to the dropdown trigger wrapper (e.g., aria-*, data-*).
clearableInSearchbooleanfalseWhether the search input is clearable.
highlightSelectionbooleantrueHighlights the selected option.
searchPlaceholderstring'Search'Placeholder for the search input.
PropertyTypeDescriptionExample
keystringUnique identifier for the option.'edit'
labelstringDisplay text for the option.'Edit Post'
type'default' | 'header' | 'divider' | 'avatar' | 'icon' | 'render' | 'search'Type of menu item to render.'header'
descriptionstringSecondary text shown below the label.'Modify post content'
descriptionIcon() => VNodeIcon rendered next to description.() => h(InfoIcon)
icon() => VNodeIcon for the option.() => h(EditIcon)
iconPlacement'left' | 'right'Position of the icon.'left'
childrenDropdownOption[]Nested options for tree navigation.[{ key: 'sub1', label: 'Sub Option' }]
srcstringImage URL for avatar type options.'/path/to/image.png'
infoTextstringAdditional text shown to the right.'+1'
titleRightSlot() => VNodeCustom content for the right side.() => h(HLTag, { ... })
render() => VNodeCustom render function for the entire option.() => h('div', { ... })
disabledbooleanDisables the option, preventing selection.true
classstringCustom CSS class to apply to the option element.'custom-option'
hrefstringURL for link-type options. When set, the option renders as an <a> tag.'https://example.com'
targetstringTarget attribute for link-type options (e.g., '_blank').'_blank'
childrenMaxHeightstringMaximum height for the submenu displaying this option's children. Falls back to parent dropdown's maxHeight if not specified.'200px'

Emits

EventArgumentsDescription
@select(key: string | number, option: DropdownOption)Fired when an option is selected.
@update:show(show: boolean)Fired when dropdown visibility changes.
@search(value: string)Fired when search input changes.
@update:searchValue(value: string)Fired when search value changes (for controlled search).
@clickoutside(event: Event)Fired when an outside interaction closes the dropdown.
@scroll(event: Event)Fired when the dropdown is scrolled (requires maxHeight to be set).
@mouseenter(event: Event)Fired when the pointer enters the menu panel, or when focus moves into the panel from outside (keyboard). Payload is often a MouseEvent or FocusEvent.
@mouseleave(event: Event)Fired when the pointer leaves the menu panel, or when focus leaves the panel entirely (keyboard). Payload is often a MouseEvent or FocusEvent.

Slots

NameParametersDescription
default()The trigger element (default slot).
header()Content displayed at the top of the dropdown menu.
footer()Content displayed at the bottom of the dropdown menu.
empty()Content displayed when no options match the search query.
option-renderer{ option: DropdownOption }Custom renderer for individual options.
loader()Loading indicator displayed during infinite scroll or async operations.