# Mac menu popover

> A macOS menu bar popover: a status item and a frosted panel whose arrow and panel are one shape, so one blur and one hairline follow the arrow. It grows from the arrow tip, reverses mid-flight, and drops its CSS blur for native Tauri or Electron vibrancy.

- Tier: Free (MIT)
- Interaction: Press
- Page: https://eased.dev/components/mac-menu-popover
- Installs: @base-ui/react
- Registry: https://eased.dev/r/mac-menu-popover.json

## Install

```bash
npx shadcn add https://eased.dev/r/mac-menu-popover.json
```

A macOS menu bar popover: a status item and a frosted panel that hangs from it on an arrow, built on Base UI Popover, the Eased floating layer and the shared `eased-mac` material.

## When to use

- A menu bar utility in a Tauri or Electron app: usage meters, timers, clipboard history, quick toggles.
- A web demo or landing page that shows a Mac app's tray panel and should look and move like the real thing.
- Anywhere a `Popover` should read as native macOS: system font, translucent material, an arrow pointing at its anchor.

## When not to use

- Ordinary web UI: use [Popover](/components/popover), which follows Eased's web material and radius.
- A list of commands with no other content: a menu is the better pattern.
- Transient feedback such as volume or brightness changes: use [Mac HUD](/components/mac-hud).

## Usage

```tsx example.tsx
import {
  MacMenuItem,
  MacMenuPopover,
  MacMenuPopoverClose,
  MacMenuPopoverContent,
  MacMenuSeparator,
  MacStatusItem,
} from "@/components/eased/mac-menu-popover/mac-menu-popover";

export function UsageMenu() {
  return (
    <MacMenuPopover>
      <MacStatusItem label="Tally: 62% of this session used">62%</MacStatusItem>
      <MacMenuPopoverContent>
        <p className="font-semibold">Tally</p>
        <p>62% of this session · resets in 1h 48m</p>
        <MacMenuSeparator />
        <MacMenuPopoverClose render={<MacMenuItem shortcut="⌘," />}>Settings…</MacMenuPopoverClose>
        <MacMenuPopoverClose render={<MacMenuItem shortcut="⌘Q" />}>Quit</MacMenuPopoverClose>
      </MacMenuPopoverContent>
    </MacMenuPopover>
  );
}
```

## Examples

### Kept inside a frame

In a mock-up, keep the panel inside the fake screen rather than the viewport.

```tsx
<MacMenuPopoverContent collisionBoundary={screenElement}>…</MacMenuPopoverContent>
```

### In a desktop tray window

On the desktop the native tray icon is the status item and a small borderless window is the panel, so render your content in `MacMenuPanel`: the same type, fills and padding, without the popover. `MacMenuItem` and `MacMenuSeparator` work inside it. In Tauri and Electron alike:

- The window draws real vibrancy and the panel uses `material="native"`, its default. A CSS blur can't see the desktop through a transparent window, so `"css"` there gives an unblurred tint; it is for web pages.
- Make the page transparent with `html, body { background: transparent; }`. shadcn's base layer paints `body` with `bg-background`, which covers the vibrancy.
- Call `useSystemAppearance()` once in the window. The vibrancy follows the system appearance and Eased's dark styles follow `.dark`; without the hook a dark Mac gets dark glass with black text.
- Size the window to the content (115px tall for the panel below) and hide it when it loses focus, as macOS does.

```tsx src/tray.tsx
import { exit } from "@tauri-apps/plugin-process";
import { useSystemAppearance } from "@/components/eased/lib/mac";
import { MacMenuItem, MacMenuPanel, MacMenuSeparator } from "@/components/eased/mac-menu-popover/mac-menu-popover";

export function TrayWindow() {
  useSystemAppearance();
  return (
    <MacMenuPanel>
      <p className="font-semibold">Tally</p>
      <p>62% of this session · resets in 1h 48m</p>
      <MacMenuSeparator />
      <MacMenuItem shortcut="⌘,">Settings…</MacMenuItem>
      {/* Electron: send an IPC message that calls app.quit() instead. */}
      <MacMenuItem shortcut="⌘Q" onClick={() => exit(0)}>Quit</MacMenuItem>
    </MacMenuPanel>
  );
}
```

`MacMenuPopover` itself is for menu bars drawn in the page: a web demo, a landing page, or an app window with its own title bar. If that window draws native vibrancy, pass `material="native"` to `MacMenuPopoverContent` too.

### In a Tauri app

Checked with Tauri 2.12, `tauri-plugin-positioner` 2.4 and `tauri-plugin-process` 2. Add the plugins with `tauri add positioner` and `tauri add process`, then turn on the tray features, which `tauri add` leaves off:

```toml src-tauri/Cargo.toml
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
```

The window, hidden until the tray icon is clicked. `windowEffects` only works with `transparent: true`; `macOSPrivateApi` is not needed (a no-op since 2.12.1).

```json src-tauri/tauri.conf.json
{
  "app": {
    "windows": [
      {
        "label": "tray",
        "url": "/tray",
        "width": 320,
        "height": 115,
        "visible": false,
        "transparent": true,
        "decorations": false,
        "resizable": false,
        "alwaysOnTop": true,
        "skipTaskbar": true,
        "visibleOnAllWorkspaces": true,
        "windowEffects": { "effects": ["popover"], "state": "active", "radius": 12 }
      }
    ]
  }
}
```

A capability for the window. `process:default` is only for `exit`; a [Mac HUD](/components/mac-hud) window gets its own.

```json src-tauri/capabilities/tray.json
{
  "identifier": "tray",
  "platforms": ["macOS"],
  "windows": ["tray"],
  "permissions": [
    "core:default",
    "core:window:allow-show",
    "core:window:allow-hide",
    "core:window:allow-set-position",
    "positioner:default",
    "process:default"
  ]
}
```

The tray icon opens the window under itself and hides it on blur. `show_menu_on_left_click(false)` replaces the deprecated `menu_on_left_click`; the icon is a black-on-transparent PNG that macOS tints as a template.

```rust src-tauri/src/lib.rs
use tauri::{
    image::Image,
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager, WindowEvent,
};
use tauri_plugin_positioner::{Position, WindowExt};

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_positioner::init())
        .plugin(tauri_plugin_process::init())
        .setup(|app| {
            // A menu bar app: no Dock icon.
            #[cfg(target_os = "macos")]
            app.set_activation_policy(tauri::ActivationPolicy::Accessory);

            TrayIconBuilder::new()
                .icon(Image::from_bytes(include_bytes!("../icons/tray.png"))?)
                .icon_as_template(true)
                .show_menu_on_left_click(false)
                .on_tray_icon_event(|tray, event| {
                    tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event);
                    if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event {
                        let Some(win) = tray.app_handle().get_webview_window("tray") else { return };
                        if win.is_visible().unwrap_or(false) {
                            let _ = win.hide();
                        } else {
                            let _ = win.move_window(Position::TrayBottomCenter);
                            let _ = win.show();
                            let _ = win.set_focus();
                        }
                    }
                })
                .build(app)?;
            Ok(())
        })
        // Hide the panel when it loses focus, as macOS does.
        .on_window_event(|window, event| {
            if window.label() == "tray" && matches!(event, WindowEvent::Focused(false)) {
                let _ = window.hide();
            }
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

### In an Electron app

The main process makes the tray icon and a hidden vibrancy window, and places the window under the icon on each click. `tray.getBounds()` can fall off every screen (an icon hidden behind the notch, or a second display), so the position is clamped to the nearest display.

```js electron/main.cjs
const path = require("node:path");
const { app, BrowserWindow, Tray, nativeImage, screen } = require("electron");

let tray, panel;

app.whenReady().then(() => {
  app.dock?.hide(); // A menu bar app: no Dock icon.

  panel = new BrowserWindow({
    width: 320,
    height: 115,
    show: false,
    frame: false,
    transparent: true,
    backgroundColor: "#00000000",
    resizable: false,
    skipTaskbar: true,
    alwaysOnTop: true,
    vibrancy: "popover",
    visualEffectState: "active",
  });
  panel.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
  panel.on("blur", () => panel.hide());
  panel.loadFile(path.join(__dirname, "../dist/index.html"), { query: { view: "tray" } });

  const icon = nativeImage.createFromPath(path.join(__dirname, "trayTemplate.png"));
  icon.setTemplateImage(true);
  tray = new Tray(icon);
  tray.on("click", () => {
    if (panel.isVisible()) return panel.hide();
    const t = tray.getBounds();
    const { workArea } = screen.getDisplayMatching(t);
    const { width, height } = panel.getBounds();
    const x = Math.min(Math.max(t.x + t.width / 2 - width / 2, workArea.x + 8), workArea.x + workArea.width - width - 8);
    const y = Math.min(Math.max(t.y + t.height + 4, workArea.y), workArea.y + workArea.height - height);
    panel.setPosition(Math.round(x), Math.round(y));
    panel.show();
    panel.focus();
  });
});
```

macOS rounds the frameless window's corners (about 10pt) and draws its shadow; Electron can't round the vibrancy further.

## API reference

### MacMenuPopover

Base UI `Popover.Root`: `open`, `defaultOpen`, `onOpenChange`, `modal` (non-modal by default), `actionsRef`.

### MacStatusItem

Base UI `Popover.Trigger` props, plus:

| Prop | Type | Default | Description |
|---|---|---|---|
| `label` | `string` | | Required accessible name, e.g. "Usage: 62% of this session used". |
| `className` | `string` | `""` | Appended to the item's classes. |

A 22px menu bar button. Children are its icon and optional short value.

### MacMenuPopoverContent

| Prop | Type | Default | Description |
|---|---|---|---|
| `width` | `number` | `320` | Panel width, px; capped at the available width. |
| `arrow` | `boolean` | `true` | Draw the arrow. Without it the panel is a plain rounded rectangle. |
| `material` | `"css" \| "native"` | `"css"` | `native` drops the CSS blur and tint for a window that draws its own vibrancy. |
| `side` | `"bottom" \| "top"` | `"bottom"` | Flips on collision; the arrow moves to the facing edge. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | |
| `sideOffset` | `number` | `6` | Gap from the status item to the arrow tip, px. |
| `alignOffset` | `number` | `0` | |
| `collisionPadding` | `number` | `8` | Space kept from the boundary, px. |
| `collisionBoundary` | `Element \| Element[] \| Rect \| "clipping-ancestors"` | viewport | The area the panel stays inside. |
| `initialFocus` | Base UI | see description | Where focus goes on open. Default: the first control when opened from the keyboard, the panel itself when clicked, so no row lights up. |
| `finalFocus` | Base UI | the status item | Where focus returns on close. |
| `container` | `HTMLElement \| null` | `document.body` | Portal target. |
| `zIndex` | `number` | `50` | |
| `className` | `string` | `""` | Appended to the content area (a flex column with 12px padding). |

The panel exposes `--pad` (12px) and `--radius-outer` (11px) for concentric children.

### MacMenuPanel

The panel's material, type, fills and 12px padding as a plain `div`, for a native tray window. `material` (`"native"`), `className`, `children`. It fills the window, so the window draws the corners.

### MacMenuItem

A `<button>` row. All button props, plus `icon` (a 16px icon on the content line) and `shortcut` (display text such as `"⌘D"`; wire the key yourself). Wrap it in `MacMenuPopoverClose` with `render` when the action should close the panel.

### MacMenuSeparator, MacMenuPopoverClose

A divider on the content lines (`className`), and Base UI `Popover.Close`.

### useSystemAppearance

From `@/components/eased/lib/mac`. Call it once in a Tauri or Electron window: it puts `.dark` on `<html>` while `prefers-color-scheme` is dark and follows changes, so the text matches the native vibrancy. It runs in an effect, so it is safe with server rendering. Eased otherwise follows `.dark` or `data-theme="dark"`, so leave it out of a web app with its own theme toggle.

## Keyboard

| Key | Action |
|---|---|
| `Enter` / `Space` on the status item | Opens or closes the panel. |
| `Tab` / `Shift+Tab` | Moves through the panel's controls; focus stays in the page. |
| `Esc` | Closes the panel and returns focus to the status item. |

## Accessibility

- The status item is a button named by `label`, with `aria-expanded` while open. Its visible text can be just a number; the label says what the number means.
- The panel is a Base UI popover (`role="dialog"`, non-modal). A keyboard open moves focus to its first control (a click focuses the panel itself); focus returns to the status item on close; Escape and an outside click dismiss it.
- Rows are real buttons. Hover and keyboard focus draw the same highlight, so focus is never invisible. The shortcut text is display only.
- The status item is 22px tall, as in the macOS menu bar; it sits in a dense row of targets, so it isn't enlarged to 44px.

## Motion

The panel grows from the arrow tip (Base UI's `--transform-origin`, which tracks the arrow) using the floating layer's transitions: it drops 6px toward the menu bar and scales from 0.95 on the `snappy` spring while fading in over 200ms, then leaves at half the distance and scale in 150ms. Open and close are CSS transitions, so a close during the open turns around mid-flight. The shadow layer copies the panel's motion state, so it moves in step. The status item presses and stays highlighted by colour only, never by scale. Reduced motion keeps a 100ms fade.

## Notes for AI

Pick this for anything that should look like a Mac menu bar extra. In a Tauri or Electron tray window render `MacMenuPanel` (native material by default) in a window with native vibrancy, a transparent `html, body`, and `useSystemAppearance()`. Compose `MacMenuPopover` › `MacStatusItem` + `MacMenuPopoverContent`, fill the content with your own layout, and use `MacMenuItem` / `MacMenuSeparator` for action rows. Always pass `label` to `MacStatusItem`.

Don't use `material="css"` in a desktop window: the blur can't see the desktop. Don't wrap the content in another card, border, shadow or blur: the panel draws its own material, hairline and shadow as one shape. Don't put a `filter`, `opacity` or `backdrop-filter` on an ancestor of the panel; it stops the blur from seeing the page. Don't scale the status item on press, and don't add animation to the panel: it already uses the shared floating motion.

## Related

- [Popover](/components/popover): the same behaviour with Eased's web material.
- [Mac HUD](/components/mac-hud): the macOS volume and brightness overlay.

## Example

`components/eased/mac-menu-popover/mac-menu-popover-demo.tsx`

```tsx
"use client";

import { useState } from "react";
import { macTextSecondary } from "../lib/mac";
import { MacDesktop } from "../lib/mac-desktop";
import {
  MacMenuItem,
  MacMenuPopover,
  MacMenuPopoverClose,
  MacMenuPopoverContent,
  MacMenuSeparator,
  MacStatusItem,
} from "./mac-menu-popover";

/** A plan-usage tracker living in the menu bar, the kind of utility people keep open all day. */
const SESSION = 62;
const WEEK = 38;

export function MacMenuPopoverDemo() {
  // The screen is the collision boundary, so the panel stays on this little desktop.
  const [screen, setScreen] = useState<HTMLDivElement | null>(null);

  return (
    <MacDesktop
      ref={setScreen}
      app="Finder"
      menus={["File", "Edit", "View", "Go"]}
      className="select-none"
      statusItems={
        <MacMenuPopover>
          <MacStatusItem label={`Tally: ${SESSION}% of this session used`}>
            <Gauge value={SESSION} />
            <span>{SESSION}%</span>
          </MacStatusItem>
          <MacMenuPopoverContent collisionBoundary={screen ?? undefined}>
            <UsagePanel />
          </MacMenuPopoverContent>
        </MacMenuPopover>
      }
    />
  );
}

/**
 * Laid out like a macOS menu bar extra (the Battery or Wi-Fi menu): 13px rows, an 11px
 * secondary line under each, slim meters, and actions as menu rows.
 */
function UsagePanel() {
  return (
    <>
      <div className="flex items-baseline justify-between gap-3">
        <p className="font-semibold">Tally</p>
        <p className={`${macTextSecondary} text-[11px]`}>Pro plan</p>
      </div>

      <MacMenuSeparator className="mt-2 mb-2.5" />

      <Usage label="Current session" value={SESSION} detail="Resets in 1h 48m · at this pace, full by 11:20" />
      <Usage label="This week" value={WEEK} detail="Resets Thu at 09:00 · 1.24M tokens today" className="mt-3" />

      <MacMenuSeparator className="mt-3 mb-1.5" />

      <MacMenuPopoverClose render={<MacMenuItem icon={<ChartIcon />} shortcut="⌘D" />}>Open Dashboard</MacMenuPopoverClose>
      <MacMenuPopoverClose render={<MacMenuItem icon={<GearIcon />} shortcut="⌘," />}>Settings…</MacMenuPopoverClose>
      <MacMenuPopoverClose render={<MacMenuItem icon={<PowerIcon />} shortcut="⌘Q" />}>Quit Tally</MacMenuPopoverClose>
    </>
  );
}

/** A labelled usage row: name and percentage, a slim meter, then a secondary line. */
function Usage({ label, value, detail, className = "" }: { label: string; value: number; detail: string; className?: string }) {
  return (
    <div className={className}>
      <div className="flex items-baseline justify-between gap-3">
        <p>{label}</p>
        <p className={`${macTextSecondary} tabular-nums`}>{value}%</p>
      </div>
      <div
        role="meter"
        aria-label={label}
        aria-valuenow={value}
        aria-valuemin={0}
        aria-valuemax={100}
        className="mt-1.5 h-1 overflow-hidden rounded-full bg-(--mac-track)"
      >
        <div className={`h-full rounded-full ${ACCENT}`} style={{ width: `${value}%` }} />
      </div>
      <p className={`${macTextSecondary} mt-1.5 text-[11px] leading-[14px] tabular-nums`}>{detail}</p>
    </div>
  );
}

/** macOS system blue, light and dark. */
const ACCENT = "bg-[rgb(0_122_255)] in-[.dark]:bg-[rgb(10_132_255)] in-data-[theme=dark]:bg-[rgb(10_132_255)]";

/* ---------- Glyphs ---------- */

/** The status item's template icon: a ring filled to the session usage, in the bar's text colour. */
function Gauge({ value }: { value: number }) {
  const c = 2 * Math.PI * 5.25;
  return (
    <svg viewBox="0 0 16 16" className="size-4" aria-hidden>
      <circle cx="8" cy="8" r="5.25" fill="none" stroke="currentColor" strokeOpacity={0.3} strokeWidth={2} />
      <circle
        cx="8"
        cy="8"
        r="5.25"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeDasharray={`${(value / 100) * c} ${c}`}
        transform="rotate(-90 8 8)"
      />
    </svg>
  );
}

const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round" } as const;

function ChartIcon() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden {...stroke}>
      <path d="M2.5 13.5h11M4.5 11V8M8 11V4.5M11.5 11V6.5" />
    </svg>
  );
}

function GearIcon() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden {...stroke}>
      <circle cx="8" cy="8" r="2" />
      <path d="M8 1.75v1.5M8 12.75v1.5M14.25 8h-1.5M3.25 8h-1.5M12.42 3.58l-1.06 1.06M4.64 11.36l-1.06 1.06M12.42 12.42l-1.06-1.06M4.64 4.64 3.58 3.58" />
    </svg>
  );
}

function PowerIcon() {
  return (
    <svg viewBox="0 0 16 16" aria-hidden {...stroke}>
      <path d="M8 2v5.5M4.75 4.25a5 5 0 1 0 6.5 0" />
    </svg>
  );
}
```
