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

Menu

A vertical navigation menu with nested options and icons. Supports unlimited nesting levels with proper indentation and visual hierarchy

Basic Usage

The menu accepts an array of options that define its structure. Each option can use the title property for simple text labels, or label for custom rendered content. Menu items support:

  • Items with icons that can be hidden when expanded (hideIconWhenExpanded)
  • Items with count badges displayed next to the title
  • Nested children with unlimited depth
  • Disabled states
  • Dividers for visual separation
  • Custom rendered items using Vue's h function
vue
<template>
  <HLMenu :options="menuOptions" v-model:value="menuValue" :default-expanded-keys="['inbox', 'assigned']" />
</template>

<script setup lang="ts">
import { HLMenu } from '@platform-ui/highrise'
import { ref } from 'vue'
import { menuOptions } from './options'

const menuValue = ref('unread')
</script>
ts
import { renderIcon } from '@platform-ui/highrise'
import {
  User01Icon,
  UserLeft01Icon,
  UserUp01Icon,
  Users01Icon,
  Home01Icon,
  BezierCurve03Icon,
} from '@gohighlevel/ghl-icons/24/outline'

export const menuOptions = [
  {
    key: 'inbox',
    title: 'My Inbox',
    icon: renderIcon(User01Icon),
    hideIconWhenExpanded: true,
    count: '12',
    children: [
      {
        key: 'assigned',
        title: 'Assigned to Me',
        icon: renderIcon(UserLeft01Icon),
        count: '10',
        children: [
          { key: 'unread', title: 'Unread', count: '10' },
          { key: 'all', title: 'All', count: '12' },
          { key: 'recent', title: 'Recent', count: '2' },
          { key: 'starred', title: 'Starred' },
        ],
      },
      { key: 'followed', title: 'Followed by me', icon: renderIcon(UserUp01Icon) },
      { key: 'chat', title: 'Internal Chat', icon: renderIcon(BezierCurve03Icon) },
    ],
  },
  {
    key: 'team-inbox',
    title: 'Team Inbox',
    icon: renderIcon(Users01Icon),
    hideIconWhenExpanded: true,
    count: '3',
    children: [
      { key: 'unread2', title: 'Unread', count: '10' },
      { key: 'all2', title: 'All', count: '12' },
      { key: 'recent2', title: 'Recent', count: '2' },
      { key: 'starred2', title: 'Starred' },
    ],
  },
  { key: 'disabled', title: 'Disabled', icon: renderIcon(User01Icon), disabled: true },
  { key: 'divider-1', type: 'divider' },
  { key: 'manual', title: 'Manual Actions', icon: renderIcon(Home01Icon), hideIconWhenExpanded: true },
]

Layout Example

Complete example showing how to use the menu in a typical sidebar layout with header, body, and footer sections.

Content Area

Active menu item: unread

Search value:

Menu collapsed: false

vue
<template>
  <HLMenuLayout
    :collapsed="layoutCollapsed"
    :show-trigger="true"
    :width="280"
    :collapsed-width="52"
    @update:collapsed="layoutCollapsed = $event"
  >
    <template #sider>
      <div class="sider">
        <!-- Header -->
        <div class="sider-header">
          <!-- Full-width button when expanded, icon-only when collapsed -->
          <HLButton
            v-if="!layoutCollapsed"
            variant="primary"
            color="blue"
            size="xs"
            style="width: 100%; margin-bottom: 10px"
          >
            <template #iconLeft>
              <MessagePlusCircleIcon />
            </template>
            New Conversation
          </HLButton>
          <HLButton
            v-else
            variant="primary"
            color="blue"
            size="xs"
            style="width: 100%; padding: 8px; margin-bottom: 8px"
            @click="layoutCollapsed = false"
          >
            <template #iconLeft>
              <MessagePlusCircleIcon />
            </template>
          </HLButton>

          <!-- Searchable select when expanded -->
          <HLSelect
            v-if="!layoutCollapsed"
            id="menu-search-select"
            v-model:value="searchValue"
            :filterable="true"
            :options="selectOptions"
            placeholder="Search or select..."
            show-search-icon
            style="width: 100%"
          >
            <template #arrow>
              <FilterLinesIcon style="width: 16px; height: 16px; color: var(--gray-600)" />
            </template>
          </HLSelect>

          <!-- Search affordance when collapsed -->
          <div v-else class="search-box" title="Click to expand and search" @click="layoutCollapsed = false">
            <SearchMdIcon style="width: 16px; height: 16px; color: var(--gray-600)" />
          </div>
        </div>

        <!-- Body - main menu -->
        <div class="sider-body">
          <HLMenu
            id="menu"
            :options="bodyMenuOptions"
            :value="activeValue"
            :collapsed="layoutCollapsed"
            :default-expanded-keys="['team-inbox', 'inbox', 'assigned']"
            :submenu-width="200"
            :indent="16"
            :root-indent="8"
            style="height: 100%"
            @update:value="activeValue = $event"
          />
        </div>

        <!-- Footer menu -->
        <HLMenu
          id="footer-menu"
          :options="footerMenuOptions"
          :value="activeValue"
          :collapsed="layoutCollapsed"
          :indent="16"
          :root-indent="8"
          @update:value="activeValue = $event"
        />
      </div>
    </template>

    <template #content>
      <div style="padding: 16px">
        <h3>Content Area</h3>
        <p>Active menu item: {{ activeValue }}</p>
        <p>Search value: {{ searchValue }}</p>
        <p>Menu collapsed: {{ layoutCollapsed }}</p>
      </div>
    </template>
  </HLMenuLayout>
</template>

<script setup lang="ts">
import { HLMenuLayout, HLMenu, HLButton, HLSelect } from '@platform-ui/highrise'
import {
  MessagePlusCircleIcon,
  SearchMdIcon,
  FilterLinesIcon,
} from '@gohighlevel/ghl-icons/24/outline'
import { ref } from 'vue'
import { bodyMenuOptions, footerMenuOptions } from './options'

const layoutCollapsed = ref(false)
const searchValue = ref(null)
const activeValue = ref('unread')

const selectOptions = [
  { label: 'My Inbox', value: 'inbox' },
  { label: 'Team Inbox', value: 'team-inbox' },
  { label: 'Settings', value: 'settings' },
  { label: 'Manual Actions', value: 'manual' },
]
</script>

<style scoped>
.sider {
  display: flex;
  flex-direction: column;
  height: 100%;
  background-color: #eceef2;
}

.sider-header {
  padding: 16px 10px 0;
  border-bottom: 1px solid var(--gray-200);
}

.sider-body {
  flex: 1;
  overflow: hidden;
}

.search-box {
  width: 100%;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: white;
  border: 1px solid var(--gray-300);
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.search-box:hover {
  background-color: var(--gray-50);
}
</style>
ts
import { renderIcon } from '@platform-ui/highrise'
import {
  User01Icon,
  UserLeft01Icon,
  UserUp01Icon,
  Users01Icon,
  Home01Icon,
  BezierCurve03Icon,
  Settings02Icon,
  LogOut01Icon,
} from '@gohighlevel/ghl-icons/24/outline'

export const bodyMenuOptions = [
  {
    key: 'inbox',
    title: 'My Inbox',
    icon: renderIcon(User01Icon),
    hideIconWhenExpanded: true,
    count: '12',
    children: [
      {
        key: 'assigned',
        title: 'Assigned to Me',
        icon: renderIcon(UserLeft01Icon),
        count: '10',
        children: [
          { key: 'unread', title: 'Unread', count: '10' },
          { key: 'all', title: 'All', count: '12' },
          { key: 'recent', title: 'Recent', count: '2' },
          { key: 'starred', title: 'Starred' },
        ],
      },
      { key: 'followed', title: 'Followed by me', icon: renderIcon(UserUp01Icon) },
      { key: 'chat', title: 'Internal Chat', icon: renderIcon(BezierCurve03Icon) },
    ],
  },
  {
    key: 'team-inbox',
    title: 'Team Inbox',
    icon: renderIcon(Users01Icon),
    hideIconWhenExpanded: true,
    count: '3',
    children: [
      { key: 'unread2', title: 'Unread', count: '10' },
      { key: 'all2', title: 'All', count: '12' },
      { key: 'recent2', title: 'Recent', count: '2' },
      { key: 'starred2', title: 'Starred' },
    ],
  },
  { key: 'disabled', title: 'Disabled', icon: renderIcon(User01Icon), disabled: true },
  { key: 'divider-1', type: 'divider' },
  { key: 'manual', title: 'Manual Actions', icon: renderIcon(Home01Icon), hideIconWhenExpanded: true },
]

export const footerMenuOptions = [
  { key: 'settings', title: 'Settings', icon: renderIcon(Settings02Icon), hideIconWhenExpanded: true },
  { key: 'logout', title: 'Log Out', icon: renderIcon(LogOut01Icon), hideIconWhenExpanded: true },
]

Accessibility

  • Give the menu container role="navigation" (or menu) plus aria-label / aria-labelledby explaining what it lists.
  • Mark the active route using aria-current="page" / aria-selected, and toggle aria-expanded on items that reveal submenus.
  • Route helper text or badge counts through aria-describedby so they are read with each item.

Imports

ts
import { HLMenu } from '@platform-ui/highrise'

Props

NameTypeDefaultDescription
idstringAuto (hr-menu-*)The id of the element. Generated automatically when omitted.
optionsMenuOption[][]Items data of menu.
valuestring | number | undefinedundefinedSelected key of the menu item.
defaultExpandedKeysArray<string | number>[]Keys expanded on initial render for uncontrolled use; the menu then manages expansion itself. Ignored when expandedKeys is set.
expandedKeysArray<string | number> | undefinedundefinedControlled expanded key list. When provided it fully drives expansion (and defaultExpandedKeys is ignored) — keep it in sync via @update:expanded-keys.
collapsedbooleanfalseWhether the menu is in collapsed state (icon-only mode).
submenuWidthnumber182Width of the dropdown submenu in collapsed mode (in pixels).
indentnumber16Indentation for nested menu items (in pixels).
rootIndentnumber8Indentation for root level menu items (in pixels).
ariaLabelstring | undefinedundefinedAccessible label for the menu container.
ariaLabelledbystring | undefinedundefinedId of an element that labels the menu container.
ariaDescribedbystring | undefinedundefinedId of an element that describes the menu container.
tooltipVariant'light' | 'dark''dark'Variant of the tooltip that shows an item's label when the menu is collapsed.

In collapsed mode only icons are visible, so hovering an item reveals its label in a tooltip. Use tooltipVariant to switch that tooltip between dark (default) and light.

Types

ts
interface MenuOption {
  key: string | number // Unique identifier
  label?: string | (() => VNode) // Text or custom render function
  title?: string // Convenience field for simple text labels (alternative to label)
  icon?: () => VNode // Optional icon
  count?: string | number // Optional count badge
  hideIconWhenExpanded?: boolean // Hide icon when menu is expanded
  disabled?: boolean // Disable the menu item
  children?: MenuOption[] // Nested menu items
  type?: 'group' | 'divider' // Special item types
}

Emits

NameTypeDescription
update:value(key: string, item: MenuOption)Emitted when the value changes
update:expanded-keys(keys: string[])Emitted when the expanded keys change. Pair with the expandedKeys prop for controlled expansion. (@update:expanded-key, singular, is also emitted as an alias.)

Methods

NameTypeDescription
deriveResponsiveState() => voidRecalculates the collapsed state of the responsive menu content. Use this method when the menu container's width changes dynamically to ensure proper expansion behavior in responsive mode.
showOption(key: string| number) => voidShow the option