Skip to content

@carrot/design-vue-menus

Vue 3 navigation menu components for the Carrot Design System. Vertical and horizontal menus with groups, flyouts, keyboard navigation, and Vue Router integration.

Installation

ts
import { CarrotMenu, CarrotMenuItem, CarrotMenuGroup, CarrotMenuDivider, CarrotMenuFlyout } from "@carrot/design-vue-menus";

Requires the CSS layer from @carrot/design-components-menus.


Components

CarrotMenu

Root menu container. Provides context (horizontal/compact/depth) to children via inject.

Usage

vue
<!-- Basic navigation -->
<CarrotMenu nav aria-label="Main navigation">
  <CarrotMenuItem href="/" label="Home" active />
  <CarrotMenuItem href="/library" label="Library" />
  <CarrotMenuItem href="/settings" label="Settings" />
</CarrotMenu>

<!-- Horizontal -->
<CarrotMenu nav horizontal aria-label="Top bar">
  <CarrotMenuItem href="/" label="Home" />
  <CarrotMenuItem href="/explore" label="Explore" />
</CarrotMenu>

<!-- Compact (icons only) -->
<CarrotMenu nav compact aria-label="Sidebar">
  <CarrotMenuItem href="/">
    <template #icon><HomeIcon /></template>
  </CarrotMenuItem>
</CarrotMenu>

<!-- Plain list (no <nav> wrapper) -->
<CarrotMenu>
  <CarrotMenuItem label="Option A" />
  <CarrotMenuItem label="Option B" />
</CarrotMenu>

Props

PropTypeDefaultDescription
navbooleanfalseWrap in <nav> element
ariaLabelstringAccessible label
horizontalbooleanfalseHorizontal layout
compactbooleanfalseIcons-only mode
tag"ul" | "ol""ul"List element tag

Exposed Methods

MethodDescription
focusFirst()Focus the first menu item
focusLast()Focus the last menu item
elDirect ref to the list element

CarrotMenuItem

Polymorphic menu item — renders as <button>, <a>, <router-link>, or custom component.

Usage

vue
<!-- Button (default) -->
<CarrotMenuItem label="Action" @click="handleAction" />

<!-- Link -->
<CarrotMenuItem href="/page" label="Page" />

<!-- Vue Router link -->
<CarrotMenuItem :to="{ name: 'dashboard' }" label="Dashboard" />

<!-- External link -->
<CarrotMenuItem href="https://example.com" target="_blank" label="External" />

<!-- Active state -->
<CarrotMenuItem href="/current" label="Current Page" active />

<!-- With icon -->
<CarrotMenuItem label="Settings">
  <template #icon><SettingsIcon /></template>
</CarrotMenuItem>

<!-- With trail badge -->
<CarrotMenuItem label="Notifications" trail="12" />

<!-- With trail slot -->
<CarrotMenuItem label="Messages">
  <template #trail><CarrotBadgeCount :count="5" /></template>
</CarrotMenuItem>

<!-- Parent item with chevron -->
<CarrotMenuItem label="More" chevron :expanded="isOpen" />

<!-- Disabled -->
<CarrotMenuItem label="Unavailable" disabled />

<!-- Custom element -->
<CarrotMenuItem :as="NuxtLink" to="/page" label="Nuxt Link" />

Props

PropTypeDefaultDescription
labelstringItem text (or use default slot)
activebooleanfalseCurrent page (sets aria-current="page")
disabledbooleanfalseDisabled state
hrefstringURL (renders as <a>)
tostring | objectVue Router destination (renders as <router-link>)
asstring | ComponentCustom render element
targetstringLink target
trailstringTrailing text/badge
chevronbooleanfalseShow expand chevron
expandedbooleanaria-expanded for parent items

Slots

SlotDescription
defaultLabel content
iconLeading icon
trailTrailing content
chevronCustom chevron icon
submenuNested content rendered after the item

CarrotMenuGroup

Labelled group of items with optional collapse behaviour.

Usage

vue
<!-- Static group -->
<CarrotMenuGroup label="Library">
  <CarrotMenuItem label="Playlists" />
  <CarrotMenuItem label="Albums" />
  <CarrotMenuItem label="Artists" />
</CarrotMenuGroup>

<!-- Collapsible -->
<CarrotMenuGroup label="Advanced" collapsible>
  <CarrotMenuItem label="Developer" />
  <CarrotMenuItem label="API Keys" />
</CarrotMenuGroup>

<!-- Controlled collapse -->
<CarrotMenuGroup label="Section" collapsible v-model:expanded="isOpen">
  <CarrotMenuItem label="Child" />
</CarrotMenuGroup>

<!-- Default collapsed -->
<CarrotMenuGroup label="Hidden" collapsible :default-expanded="false">
  <CarrotMenuItem label="Revealed on expand" />
</CarrotMenuGroup>

Props

PropTypeDefaultDescription
labelstringGroup heading
collapsiblebooleanfalseEnable expand/collapse
expandedbooleanControlled expanded state (v-model:expanded)
defaultExpandedbooleantrueInitial state when uncontrolled
idstringautoID for aria-labelledby

CarrotMenuFlyout

Nested submenu that appears on hover or click.

Usage

vue
<!-- Hover flyout (default) -->
<CarrotMenuFlyout label="More Options">
  <CarrotMenuItem label="Export" />
  <CarrotMenuItem label="Share" />
</CarrotMenuFlyout>

<!-- Click trigger -->
<CarrotMenuFlyout label="Actions" trigger="click">
  <CarrotMenuItem label="Edit" />
  <CarrotMenuItem label="Delete" />
</CarrotMenuFlyout>

<!-- Custom trigger -->
<CarrotMenuFlyout>
  <template #trigger="{ open, toggle, attrs }">
    <button v-bind="attrs" @click="toggle">
      Custom trigger {{ open ? '▼' : '▶' }}
    </button>
  </template>
  <CarrotMenuItem label="Sub-option" />
</CarrotMenuFlyout>

<!-- Alignment -->
<CarrotMenuFlyout label="Options" align="left">
  <CarrotMenuItem label="Left-aligned panel" />
</CarrotMenuFlyout>

<!-- Controlled -->
<CarrotMenuFlyout v-model:open="flyoutOpen" label="Controlled">
  <CarrotMenuItem label="Option" />
</CarrotMenuFlyout>

Props

PropTypeDefaultDescription
alignMenuFlyoutAlignment"right"Panel alignment
openbooleanControlled open state (v-model:open)
trigger"hover" | "click""hover"How to open
labelstringBuilt-in trigger label
disabledbooleanDisable the built-in trigger

Trigger Slot

PropertyTypeDescription
openbooleanCurrent open state
toggle() => voidToggle open/close
attrsobjectARIA attrs to spread onto trigger element

CarrotMenuDivider

Visual separator between items.

vue
<CarrotMenuItem label="Edit" />
<CarrotMenuDivider />
<CarrotMenuItem label="Delete" />

Composable

useMenuKeyboard(options)

Arrow-key navigation for menu containers. Horizontal-aware (swaps up/down for left/right). Supports wrap-around and Home/End.

ts
import { useMenuKeyboard } from "@carrot/design-vue-menus";

const menuEl = ref<HTMLElement | null>(null);
const { focusFirst, focusLast } = useMenuKeyboard({
  containerRef: menuEl,
  horizontal: false,
  wrap: true, // default
});

Keyboard Navigation

KeyVerticalHorizontal
ArrowDown / ArrowRightNext itemNext item
ArrowUp / ArrowLeftPrevious itemPrevious item
HomeFirst itemFirst item
EndLast itemLast item
EscapeClose flyoutClose flyout

Nesting

Menus auto-track nesting depth via provide/inject. Each CarrotMenu increments the depth counter, so nested menus know their level (0 = root, 1 = first nested, etc.).


Dependencies

PackagePurpose
@carrot/design-components-menusCSS tokens + generated styles

Optional peer: vue-router (externalized, only needed if using to prop on items).

Build

bash
npm run build

Built with Vite + vite-plugin-dts. Outputs ES module with TypeScript declarations.


Usage Guide

Setup

ts
// main.ts or component
import { CarrotMenu, CarrotMenuItem, CarrotMenuGroup, CarrotMenuDivider, CarrotMenuFlyout } from "@carrot/design-vue-menus";
import "@carrot/design-components-menus";

CarrotMenu — Practical Examples

vue
<script setup lang="ts">
import { CarrotMenu, CarrotMenuItem, CarrotMenuGroup, CarrotMenuDivider } from "@carrot/design-vue-menus";
import { useRoute } from "vue-router";

const route = useRoute();
</script>

<template>
  <CarrotMenu nav aria-label="Main navigation">
    <CarrotMenuGroup label="Content">
      <CarrotMenuItem :to="{ name: 'home' }" label="Home" :active="route.name === 'home'" />
      <CarrotMenuItem :to="{ name: 'library' }" label="Library" :active="route.name === 'library'" />
      <CarrotMenuItem :to="{ name: 'explore' }" label="Explore" :active="route.name === 'explore'" />
    </CarrotMenuGroup>

    <CarrotMenuDivider />

    <CarrotMenuGroup label="Account">
      <CarrotMenuItem :to="{ name: 'settings' }" label="Settings" :active="route.name === 'settings'" />
      <CarrotMenuItem label="Sign out" @click="signOut" />
    </CarrotMenuGroup>
  </CarrotMenu>
</template>

Horizontal Top Bar

vue
<script setup lang="ts">
import { CarrotMenu, CarrotMenuItem, CarrotMenuFlyout, CarrotMenuDivider } from "@carrot/design-vue-menus";
</script>

<template>
  <CarrotMenu nav horizontal aria-label="Top navigation">
    <CarrotMenuItem href="/" label="Home" />
    <CarrotMenuItem href="/products" label="Products" />

    <CarrotMenuFlyout label="More" trigger="click">
      <CarrotMenuItem label="Documentation" href="/docs" />
      <CarrotMenuItem label="Blog" href="/blog" />
      <CarrotMenuDivider />
      <CarrotMenuItem label="Contact" href="/contact" />
    </CarrotMenuFlyout>
  </CarrotMenu>
</template>

Collapsible Sidebar with Icon-Only Compact Mode

vue
<script setup lang="ts">
import { ref } from "vue";
import { CarrotMenu, CarrotMenuItem, CarrotMenuGroup } from "@carrot/design-vue-menus";
import HomeIcon from "./icons/HomeIcon.vue";
import SettingsIcon from "./icons/SettingsIcon.vue";

const collapsed = ref(false);
</script>

<template>
  <CarrotMenu nav :compact="collapsed" aria-label="Sidebar">
    <CarrotMenuGroup label="Navigation" collapsible>
      <CarrotMenuItem href="/">
        <template #icon><HomeIcon /></template>
        <span v-if="!collapsed">Home</span>
      </CarrotMenuItem>
      <CarrotMenuItem href="/settings">
        <template #icon><SettingsIcon /></template>
        <span v-if="!collapsed">Settings</span>
      </CarrotMenuItem>
    </CarrotMenuGroup>
  </CarrotMenu>
  <button @click="collapsed = !collapsed">Toggle sidebar</button>
</template>

useMenuKeyboard — Usage Example

vue
<script setup lang="ts">
import { ref } from "vue";
import { useMenuKeyboard } from "@carrot/design-vue-menus";

const menuEl = ref<HTMLElement | null>(null);
const { focusFirst, focusLast } = useMenuKeyboard({
  containerRef: menuEl,
  horizontal: false,
  wrap: true,
});
</script>

<template>
  <div ref="menuEl" role="menu">
    <button role="menuitem">Option A</button>
    <button role="menuitem">Option B</button>
    <button role="menuitem">Option C</button>
  </div>
  <button @click="focusFirst">Focus first</button>
</template>

Common Patterns

Dynamic Active State without Vue Router

vue
<script setup lang="ts">
import { ref } from "vue";
import { CarrotMenu, CarrotMenuItem } from "@carrot/design-vue-menus";

const activeItem = ref("home");
const items = [
  { id: "home", label: "Home" },
  { id: "about", label: "About" },
  { id: "contact", label: "Contact" },
];
</script>

<template>
  <CarrotMenu nav aria-label="Navigation">
    <CarrotMenuItem
      v-for="item in items"
      :key="item.id"
      :label="item.label"
      :active="activeItem === item.id"
      @click="activeItem = item.id"
    />
  </CarrotMenu>
</template>

Notification Badge in Trail Slot

vue
<template>
  <CarrotMenu nav aria-label="App navigation">
    <CarrotMenuItem href="/inbox" label="Inbox">
      <template #trail>
        <span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
      </template>
    </CarrotMenuItem>
  </CarrotMenu>
</template>

Nested Flyout (Context Menu Pattern)

vue
<template>
  <CarrotMenu>
    <CarrotMenuItem label="File" />
    <CarrotMenuFlyout label="Edit" trigger="click" align="right">
      <CarrotMenuItem label="Cut" @click="cut" />
      <CarrotMenuItem label="Copy" @click="copy" />
      <CarrotMenuItem label="Paste" @click="paste" />
      <CarrotMenuDivider />
      <CarrotMenuItem label="Select All" @click="selectAll" />
    </CarrotMenuFlyout>
  </CarrotMenu>
</template>

Carrot