Getting Started
IntroductionHow to Use
Components
FrameMenuAlertAccordionDialogTabsToastNewButtonInputSwitchTextareaRadio GroupCheckboxChartComboboxSelectNewAlert DialogSoonAvatarSoonBreadcrumbSoonButton GroupSoonCalendarSoonDatepickerSoonDaterangepickerSoonSheetSoonNumber InputSoonPaginationSoonTableSoon

Menu

Displays a button or a component that looks like a button.
Installation
Expand

npx @left4code/cosmic-ui-cli@latest add menu
 
Expand

This writes the component and everything it imports, then installs the packages it needs. Run npx @left4code/cosmic-ui-cli@latest initfirst if you haven't already (it detects Vue automatically).

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @zag-js/menu @zag-js/vue @zag-js/presence

This component is built directly on the Zag.js state machine (not a wrapper), plus two small shared primitives used by every portal-based component in this library: an SSR-safePortaland ausePresencecomposable that keeps content mounted through its exit animation. Vue SFCs can't export multiple components from one file, so Menu is a folder instead of a single file — copy all of it into your project.

components/ui/portal.vue
Expand

<script setup lang="ts">
import { ref, onMounted } from "vue";

// Astro server-renders islands, and Teleport needs a real `document` to move
// into. Stay `disabled` (render inline) until mounted on the client, then
// teleport.
const mounted = ref(false);
onMounted(() => (mounted.value = true));
</script>

<template>
  <Teleport to="body" :disabled="!mounted">
    <slot />
  </Teleport>
</template>
 
Expand
components/ui/presence.ts
Expand

import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as presence from "@zag-js/presence";

/**
 * Keeps a node mounted through its exit animation.
 *
 * `open` flips to false immediately, but `present` stays true until the
 * node's CSS `animationend` fires (detected via the returned ref callback),
 * so exit animations (`data-[state=closed]:animate-out` etc.) actually get
 * to play instead of the node disappearing on the same frame.
 */
function usePresence(open: MaybeRefOrGetter<boolean>) {
  const service = useMachine(
    presence.machine,
    computed(() => ({ present: toValue(open) }))
  );
  const api = computed(() => presence.connect(service, normalizeProps));

  return {
    present: computed(() => api.value.present),
    setNode: (node: HTMLElement | null) => api.value.setNode(node),
  };
}

export { usePresence };
 
Expand

Copy and paste the following folder into your project.

components/ui/menu/context.ts
Expand

import type { InjectionKey, ComputedRef } from "vue";
import type * as menu from "@zag-js/menu";

type MenuApi = ComputedRef<ReturnType<typeof menu.connect>>;

const MenuApiKey: InjectionKey<MenuApi> = Symbol("menu-api");

export { MenuApiKey };
export type { MenuApi };
 
Expand
components/ui/menu/MenuRoot.vue
Expand

<script setup lang="ts">
import { computed, provide, useAttrs, useId } from "vue";
import { useMachine, normalizeProps } from "@zag-js/vue";
import * as menu from "@zag-js/menu";
import { MenuApiKey } from "./context";

// Vue's `defineProps<T>()` type macro can't statically resolve `menu.Props`
// (an externally-defined, multi-interface type) at compile time, so props
// are forwarded untyped via `useAttrs()` instead — the Vue equivalent of
// React's `{...rest}` spread.
defineOptions({ inheritAttrs: false });
const attrs = useAttrs() as Partial<menu.Props>;
const id = useId();

const service = useMachine(
  menu.machine,
  computed(() => ({ id, ...attrs }))
);
const api = computed(() => menu.connect(service, normalizeProps));

provide(MenuApiKey, api);
</script>

<template>
  <slot />
</template>
 
Expand
components/ui/menu/MenuTrigger.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { ChevronDown } from "@lucide/vue";
import Button from "@/components/ui/button.vue";
import { MenuApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(MenuApiKey);
if (!injectedApi) throw new Error("Menu parts must be used within <MenuRoot>");
const api = injectedApi;
</script>

<template>
  <Button
    v-bind="api.getTriggerProps()"
    :class="
      twMerge(['data-[state=open]:drop-shadow-[0_0px_20px_var(--color-primary)]', className])
    "
  >
    <slot />
    <span
      v-bind="api.getIndicatorProps()"
      class="ms-auto transition-transform group-data-[state=open]:rotate-180"
    >
      <ChevronDown class="size-4" />
    </span>
  </Button>
</template>
 
Expand
components/ui/menu/MenuContent.vue
Expand

<script setup lang="ts">
import { computed, inject } from "vue";
import { twMerge } from "tailwind-merge";
import Portal from "@/components/ui/portal.vue";
import Frame, { parsePaths } from "@/components/ui/frame.vue";
import { usePresence } from "@/components/ui/presence";
import { MenuApiKey } from "./context";

const { class: className } = defineProps<{ class?: string }>();

const injectedApi = inject(MenuApiKey);
if (!injectedApi) throw new Error("Menu parts must be used within <MenuRoot>");
const api = injectedApi;

const presence = usePresence(() => api.value.open);

const positionerStyle = computed(() => ({
  ...api.value.getPositionerProps().style,
  zIndex: 70,
}));

const framePaths = parsePaths(
  '[{"show":false,"style":{"strokeWidth":"1","stroke":"var(--color-frame-1-stroke)","fill":"var(--color-frame-1-fill)"},"path":[["M","14","6"],["L","50% - 7","6"],["L","50% - 2","0"],["L","50% + 4","0"],["L","50% + 9","6"],["L","100% - 13","6"],["L","100% + 0","19"],["L","100% + 0","100% - 26"],["L","100% - 13","100% - 12"],["L","50% + 13","100% - 12"],["L","50% - 0","100% + 0"],["L","0% + 14","100% + 0"],["L","0% + 0","100% - 13"],["L","0","0% + 19"],["L","14","6"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-2-stroke)","fill":"var(--color-frame-2-fill)"},"path":[["M","50% + 16","100% - 8"],["L","50% + 25","100% - 8"],["L","50% + 18","100% - 2"],["L","50% + 9","100% - 2"],["L","50% + 16","100% - 8"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-3-stroke)","fill":"var(--color-frame-3-fill)"},"path":[["M","50% + 30","100% - 8"],["L","50% + 37","100% - 8"],["L","50% + 32","100% - 3"],["L","50% + 25","100% - 3"],["L","50% + 30","100% - 8"]]},{"show":true,"style":{"strokeWidth":"1","stroke":"var(--color-frame-4-stroke)","fill":"var(--color-frame-4-fill)"},"path":[["M","50% + 42","100% - 8"],["L","50% + 48","100% - 8"],["L","50% + 44","100% - 4"],["L","50% + 38","100% - 4"],["L","50% + 42","100% - 8"]]}]'
);
</script>

<template>
  <Portal>
    <div v-bind="api.getPositionerProps()" :style="positionerStyle">
      <div
        v-bind="api.getContentProps()"
        :ref="presence.setNode"
        :hidden="!presence.present.value"
        :class="
          twMerge([
            'group relative min-w-(--reference-width) px-6 py-7 outline-none mt-1.5',
            `[&[data-state='open']]:animate-in [&[data-state='open']]:zoom-in-80 [&[data-state='open']]:fade-in-0 [&[data-state='open']]:duration-200 [&[data-state='open'][data-placement='bottom-start']]:slide-in-from-top-2 [&[data-state='open'][data-placement='left-start']]:slide-in-from-right-2 [&[data-state='open'][data-placement='right-start']]:slide-in-from-left-2 [&[data-state='open'][data-placement='top-start']]:slide-in-from-bottom-2`,
            `[&[data-state='closed']]:animate-out [&[data-state='closed']]:zoom-out-80 [&[data-state='closed']]:fade-out-0 [&[data-state='closed']]:duration-200`,
            '[--color-frame-1-stroke:var(--color-primary)]',
            '[--color-frame-1-fill:var(--color-primary)]/20',
            '[--color-frame-2-stroke:var(--color-accent)]',
            '[--color-frame-2-fill:var(--color-accent)]/40',
            '[--color-frame-3-stroke:var(--color-accent)]',
            '[--color-frame-3-fill:var(--color-accent)]/40',
            '[--color-frame-4-stroke:var(--color-accent)]',
            '[--color-frame-4-fill:var(--color-accent)]/40',
            className,
          ])
        "
      >
        <div class="absolute inset-0 group-data-[placement=top-start]:scale-y-[-1]">
          <Frame :paths="framePaths" enable-backdrop-blur />
        </div>
        <div class="relative flex flex-col gap-2.5">
          <slot />
        </div>
      </div>
    </div>
  </Portal>
</template>
 
Expand
components/ui/menu/MenuItem.vue
Expand

<script setup lang="ts">
import { inject } from "vue";
import { twMerge } from "tailwind-merge";
import { MenuApiKey } from "./context";

const { class: className, value } = defineProps<{ class?: string; value: string }>();

const injectedApi = inject(MenuApiKey);
if (!injectedApi) throw new Error("Menu parts must be used within <MenuRoot>");
const api = injectedApi;
</script>

<template>
  <div
    v-bind="api.getItemProps({ value })"
    :class="
      twMerge([
        'cursor-pointer flex items-center -mx-3 -my-0.5 px-3 py-0.5 border border-transparent hover:border-primary/30 hover:bg-primary/10 data-[highlighted]:border-primary/30 data-[highlighted]:bg-primary/10',
        className,
      ])
    "
  >
    <slot />
  </div>
</template>
 
Expand
components/ui/menu/index.ts
Expand

export { default as MenuRoot } from "./MenuRoot.vue";
export { default as MenuTrigger } from "./MenuTrigger.vue";
export { default as MenuContent } from "./MenuContent.vue";
export { default as MenuItem } from "./MenuItem.vue";
 
Expand

Update the import paths to match your project setup.

Usage
Expand

import { MenuRoot, MenuTrigger, MenuContent, MenuItem } from "@/components/ui/menu";
 
Expand
Expand

<MenuRoot>
  <MenuTrigger class="w-56">
    <PenLine class="size-4 me-2.5" /> Actions
  </MenuTrigger>
  <MenuContent>
    <MenuItem value="edit">
      <FilePenLine class="size-4 me-2.5" /> Edit
    </MenuItem>
    <MenuItem value="duplicate">
      <CopySlash class="size-4 me-2.5" /> Duplicate
    </MenuItem>
    <MenuItem value="delete">
      <Eraser class="size-4 me-2.5" /> Delete
    </MenuItem>
    <MenuItem value="export">
      <FileUp class="size-4 me-2.5" /> Export...
    </MenuItem>
  </MenuContent>
</MenuRoot>
 
Expand
Powered by synthetic caffeine · Deployed by Left4code · Signal traceable on GitHub.