Skip to content

@carrot/design-vue-dropdowns

Vue 3 dropdown components for the Carrot Design System. Compound component architecture with provide/inject, full keyboard navigation, and reusable composables.

Installation

ts
import {
  CarrotDropdown,
  CarrotDropdownTrigger,
  CarrotDropdownMenu,
  CarrotDropdownItem,
  CarrotDropdownGroup,
  CarrotDropdownDivider,
} from "@carrot/design-vue-dropdowns";

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

Components

CarrotDropdown

Root compound component. Manages open state, provides context to children via injection.

Usage

vue
<!-- Basic -->
<CarrotDropdown>
  <CarrotDropdownTrigger>
    <CarrotButton>Options</CarrotButton>
  </CarrotDropdownTrigger>
  <CarrotDropdownMenu>
    <CarrotDropdownItem @select="handleEdit">Edit</CarrotDropdownItem>
    <CarrotDropdownItem @select="handleDuplicate">Duplicate</CarrotDropdownItem>
    <CarrotDropdownDivider />
    <CarrotDropdownItem variant="danger" @select="handleDelete">Delete</CarrotDropdownItem>
  </CarrotDropdownMenu>
</CarrotDropdown>

<!-- Controlled open state -->
<CarrotDropdown v-model:open="isOpen">
  ...
</CarrotDropdown>

<!-- With scoped slot for external state access -->
<CarrotDropdown v-slot="{ isOpen, toggle, close }">
  <CarrotDropdownTrigger>
    <CarrotButton>{{ isOpen ? 'Close' : 'Open' }}</CarrotButton>
  </CarrotDropdownTrigger>
  <CarrotDropdownMenu>
    <CarrotDropdownItem>Option</CarrotDropdownItem>
  </CarrotDropdownMenu>
</CarrotDropdown>

<!-- Keep open after selection -->
<CarrotDropdown :close-on-select="false">
  ...
</CarrotDropdown>

Props

PropTypeDefaultDescription
openbooleanControlled open state (v-model:open)
closeOnSelectbooleantrueClose when an item is selected
closeOnClickOutsidebooleantrueClose on outside click
closeOnEscapebooleantrueClose on Escape key

Events

EventPayloadDescription
update:openbooleanOpen state changed
selectunknownItem selected

Scoped Slot

PropertyTypeDescription
isOpenbooleanCurrent open state
toggle() => voidToggle open/close
close() => voidClose the dropdown

CarrotDropdownTrigger

Wraps the trigger element. Handles click-to-toggle, aria-haspopup, aria-expanded, and keyboard open (ArrowDown/Enter/Space).

vue
<CarrotDropdownTrigger>
  <CarrotButton>Click me</CarrotButton>
</CarrotDropdownTrigger>

CarrotDropdownMenu

The popup panel. Auto-focuses the first item on open. Full keyboard navigation via useMenuKeyboard.

vue
<CarrotDropdownMenu align="bottom-end">
  <CarrotDropdownItem>Option A</CarrotDropdownItem>
  <CarrotDropdownItem>Option B</CarrotDropdownItem>
</CarrotDropdownMenu>

Props

PropTypeDefaultDescription
alignDropdownAlignment"bottom-start"Menu positioning
fullbooleanfalseMatch trigger width

CarrotDropdownItem

Interactive menu item — polymorphic (<button> or <a> when href provided).

vue
<!-- Standard item -->
<CarrotDropdownItem value="edit" @select="handleSelect">
  <template #icon><EditIcon /></template>
  Edit
  <template #trail><kbd>⌘E</kbd></template>
</CarrotDropdownItem>

<!-- Link item -->
<CarrotDropdownItem href="/settings">Settings</CarrotDropdownItem>

<!-- With description -->
<CarrotDropdownItem>
  Export
  <template #description>Download as CSV</template>
</CarrotDropdownItem>

<!-- Danger variant -->
<CarrotDropdownItem variant="danger">Delete</CarrotDropdownItem>

<!-- Disabled -->
<CarrotDropdownItem disabled>Unavailable</CarrotDropdownItem>

<!-- Selected / checked -->
<CarrotDropdownItem :selected="isChecked">Toggle option</CarrotDropdownItem>

Props

PropTypeDefaultDescription
valueunknownValue emitted on select
variantDropdownItemVariantVisual variant (e.g. "danger")
hrefstringRender as link
disabledbooleanfalseDisabled state
selectedbooleanfalseSelected/checked state

Events

EventPayloadDescription
selectunknownItem clicked (value prop)

Slots

SlotDescription
defaultItem label
iconLeading icon
descriptionSecondary description text
trailTrailing content (shortcuts, badges)

CarrotDropdownGroup

Groups items with an optional heading.

vue
<CarrotDropdownGroup label="Actions">
  <CarrotDropdownItem>Edit</CarrotDropdownItem>
  <CarrotDropdownItem>Duplicate</CarrotDropdownItem>
</CarrotDropdownGroup>

CarrotDropdownDivider

Visual separator between items.

vue
<CarrotDropdownDivider />

Composables

useClickOutside(targets, handler, enabled?)

Detects clicks outside multiple element refs using composedPath() for shadow DOM compatibility.

ts
import { useClickOutside } from "@carrot/design-vue-dropdowns";

const el = ref<HTMLElement | null>(null);
useClickOutside(
  [el],
  () => console.log("clicked outside"),
  () => isActive.value,
);

useMenuKeyboard(menuEl, opts?)

Full keyboard navigation for menu-like components: ArrowUp/Down, Home/End, Escape, Tab, and single-character typeahead.

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

const menuEl = ref<HTMLElement | null>(null);
const { onKeydown, focusFirst } = useMenuKeyboard(menuEl, {
  onEscape: () => close(),
  onTab: () => close(),
});

Keyboard Navigation

KeyAction
ArrowDown / Enter / Space (on trigger)Open menu
ArrowDown / ArrowUpMove focus between items
Home / EndJump to first / last item
Enter / Space (on item)Select item
EscapeClose menu, return focus to trigger
TabClose menu
Any letterTypeahead — focus next matching item

Dependencies

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

Build

bash
npm run build

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


Usage Guide

Setup

ts
import {
  CarrotDropdown,
  CarrotDropdownTrigger,
  CarrotDropdownMenu,
  CarrotDropdownItem,
  CarrotDropdownGroup,
  CarrotDropdownDivider,
} from "@carrot/design-vue-dropdowns";
import "@carrot/design-components-dropdowns";

CarrotDropdown

Basic action menu

vue
<script setup lang="ts">
import {
  CarrotDropdown,
  CarrotDropdownTrigger,
  CarrotDropdownMenu,
  CarrotDropdownItem,
  CarrotDropdownDivider,
} from "@carrot/design-vue-dropdowns";
import { CarrotButton } from "@carrot/design-vue-buttons";

const emit = defineEmits<{ edit: []; duplicate: []; delete: [] }>();
</script>

<template>
  <CarrotDropdown>
    <CarrotDropdownTrigger>
      <CarrotButton variant="ghost" icon aria-label="Actions">
        <MoreIcon />
      </CarrotButton>
    </CarrotDropdownTrigger>
    <CarrotDropdownMenu align="bottom-end">
      <CarrotDropdownItem @select="emit('edit')">
        <template #icon><EditIcon /></template>
        Edit
      </CarrotDropdownItem>
      <CarrotDropdownItem @select="emit('duplicate')">
        <template #icon><CopyIcon /></template>
        Duplicate
      </CarrotDropdownItem>
      <CarrotDropdownDivider />
      <CarrotDropdownItem variant="danger" @select="emit('delete')">
        <template #icon><TrashIcon /></template>
        Delete
      </CarrotDropdownItem>
    </CarrotDropdownMenu>
  </CarrotDropdown>
</template>

Controlled dropdown with grouped items

vue
<script setup lang="ts">
import {
  CarrotDropdown,
  CarrotDropdownTrigger,
  CarrotDropdownMenu,
  CarrotDropdownItem,
  CarrotDropdownGroup,
} from "@carrot/design-vue-dropdowns";
import { CarrotButton } from "@carrot/design-vue-buttons";
import { ref } from "vue";

const isOpen = ref(false);

function onSelect(value: string) {
  console.log("selected:", value);
  isOpen.value = false;
}
</script>

<template>
  <CarrotDropdown v-model:open="isOpen">
    <CarrotDropdownTrigger>
      <CarrotButton>{{ isOpen ? "Close" : "Sort by" }}</CarrotButton>
    </CarrotDropdownTrigger>
    <CarrotDropdownMenu>
      <CarrotDropdownGroup label="Sort Order">
        <CarrotDropdownItem value="asc" @select="onSelect">A → Z</CarrotDropdownItem>
        <CarrotDropdownItem value="desc" @select="onSelect">Z → A</CarrotDropdownItem>
      </CarrotDropdownGroup>
      <CarrotDropdownGroup label="Sort By">
        <CarrotDropdownItem value="name" @select="onSelect">Name</CarrotDropdownItem>
        <CarrotDropdownItem value="date" @select="onSelect">Date added</CarrotDropdownItem>
        <CarrotDropdownItem value="plays" @select="onSelect">Play count</CarrotDropdownItem>
      </CarrotDropdownGroup>
    </CarrotDropdownMenu>
  </CarrotDropdown>
</template>
vue
<template>
  <CarrotDropdown>
    <CarrotDropdownTrigger>
      <CarrotButton variant="secondary">File</CarrotButton>
    </CarrotDropdownTrigger>
    <CarrotDropdownMenu>
      <CarrotDropdownItem value="new">
        New
        <template #trail><kbd>⌘N</kbd></template>
      </CarrotDropdownItem>
      <CarrotDropdownItem value="open">
        Open
        <template #trail><kbd>⌘O</kbd></template>
      </CarrotDropdownItem>
      <CarrotDropdownItem value="save">
        Save
        <template #trail><kbd>⌘S</kbd></template>
      </CarrotDropdownItem>
    </CarrotDropdownMenu>
  </CarrotDropdown>
</template>

Common Patterns

vue
<script setup lang="ts">
import {
  CarrotDropdown,
  CarrotDropdownTrigger,
  CarrotDropdownMenu,
  CarrotDropdownItem,
} from "@carrot/design-vue-dropdowns";
import { CarrotButton } from "@carrot/design-vue-buttons";
import { ref } from "vue";

const selected = ref<string[]>([]);

function toggle(value: string) {
  if (selected.value.includes(value)) {
    selected.value = selected.value.filter((v) => v !== value);
  } else {
    selected.value = [...selected.value, value];
  }
}
</script>

<template>
  <!-- closeOnSelect=false keeps the menu open while toggling -->
  <CarrotDropdown :close-on-select="false">
    <CarrotDropdownTrigger>
      <CarrotButton>Genres ({{ selected.length }})</CarrotButton>
    </CarrotDropdownTrigger>
    <CarrotDropdownMenu>
      <CarrotDropdownItem
        v-for="genre in ['Electronic', 'Jazz', 'Rock', 'Classical']"
        :key="genre"
        :value="genre"
        :selected="selected.includes(genre)"
        @select="toggle(genre)"
      >
        {{ genre }}
      </CarrotDropdownItem>
    </CarrotDropdownMenu>
  </CarrotDropdown>
</template>

Carrot