Menu

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

Install the following dependencies:

pnpm
npm
yarn
bun
yarn add @zag-js/menu @zag-js/react @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-safe Portal and a usePresence hook that keeps content mounted through its exit animation. Copy all three files into your project.

components/ui/portal.tsx
Expand

import { useSyncExternalStore } from "react";
import { createPortal } from "react-dom";

const subscribe = () => () => {};

/**
 * Renders children into document.body on the client, and inline on the server.
 *
 * The server check matters: Astro server-renders islands, and `createPortal`
 * needs a real `document`.
 */
function Portal({ children }: React.PropsWithChildren) {
  const isServer = useSyncExternalStore(
    subscribe,
    () => false,
    () => true
  );

  if (isServer) return <>{children}</>;

  return createPortal(children, document.body);
}

export { Portal };
 
Expand
components/ui/presence.ts
Expand

import { useMachine, normalizeProps } from "@zag-js/react";
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 `ref`), 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: boolean) {
  const service = useMachine(presence.machine, { present: open });
  const api = presence.connect(service, normalizeProps);

  return { present: api.present, ref: api.setNode };
}

export { usePresence };
 
Expand

Copy and paste the following code into your project.

components/ui/menu.tsx
Expand

import { createContext, useContext, useId } from "react";
import { twMerge } from "tailwind-merge";
import { useMachine, normalizeProps } from "@zag-js/react";
import * as menu from "@zag-js/menu";
import { Frame, parsePaths } from "@/components/ui/frame";
import { Button } from "@/components/ui/button";
import { ChevronDown } from "lucide-react";
import { Portal } from "@/components/ui/portal";
import { usePresence } from "@/components/ui/presence";

const MenuContext = createContext<ReturnType<typeof menu.connect> | null>(null);

function useMenuContext() {
  const api = useContext(MenuContext);
  if (!api) throw new Error("Menu parts must be used within <MenuRoot>");
  return api;
}

function MenuRoot({
  children,
  ...rest
}: React.PropsWithChildren<Partial<menu.Props>>) {
  const service = useMachine(menu.machine, { id: useId(), ...rest });
  const api = menu.connect(service, normalizeProps);

  return <MenuContext.Provider value={api}>{children}</MenuContext.Provider>;
}

function MenuTrigger({
  children,
  className,
}: React.PropsWithChildren<{ className?: string }>) {
  const api = useMenuContext();

  return (
    <Button
      {...api.getTriggerProps()}
      className={twMerge([
        "data-[state=open]:drop-shadow-[0_0px_20px_var(--color-primary)]",
        className,
      ])}
    >
      {children}
      <span
        {...api.getIndicatorProps()}
        className="ms-auto transition-transform group-data-[state=open]:rotate-180"
      >
        <ChevronDown className="size-4" />
      </span>
    </Button>
  );
}

function MenuContent({
  children,
  className,
}: React.PropsWithChildren<{ className?: string }>) {
  const api = useMenuContext();
  const { present, ref } = usePresence(api.open);

  const positionerProps = api.getPositionerProps();

  return (
    <Portal>
      <div {...positionerProps} style={{ ...positionerProps.style, zIndex: 70 }}>
        <div
          {...api.getContentProps()}
          ref={ref}
          hidden={!present}
          className={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 className="absolute inset-0 group-data-[placement=top-start]:scale-y-[-1]">
            <Frame
              paths={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"]]}]'
              )}
              enableBackdropBlur={true}
            />
          </div>
          <div className="relative flex flex-col gap-2.5">{children}</div>
        </div>
      </div>
    </Portal>
  );
}

function MenuItem({
  children,
  className,
  value,
}: React.PropsWithChildren<{ className?: string; value: string }>) {
  const api = useMenuContext();

  return (
    <div
      {...api.getItemProps({ value })}
      className={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,
      ])}
    >
      {children}
    </div>
  );
}

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