# Mac HUD

> The macOS volume and brightness overlay: a frosted, click-through HUD in the classic 200×200 square or the macOS 26 pill. Repeat changes update it in place and restart its 1.1s hold; it never replays its entrance.

- Tier: Free (MIT)
- Interaction: Press
- Page: https://eased.dev/components/mac-hud
- Installs: motion
- Registry: https://eased.dev/r/mac-hud.json

## Install

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

The macOS volume and brightness overlay: a frosted, click-through HUD that shows a level for a moment and updates in place while it is up, in the classic square or the macOS 26 pill.

## When to use

- A Mac app (Tauri, Electron or a web app that looks native) that changes a level from a shortcut or a media key: volume, display or keyboard brightness, or your own level such as playback speed.
- Feedback for a key held down: every repeat moves the bar without the HUD flickering or re-entering.
- A desktop-style web demo where the overlay should feel like the system's own.

## When not to use

- A level people set by dragging: use a slider, which already shows its value.
- Messages and confirmations ("Copied", "Saved"): announce them in a toast or inline status.
- Anything people must act on: the HUD ignores the pointer and leaves after 1.1 seconds. For a reversible action use [Undo Pill](/components/undo-pill).

## Usage

`useMacHud` holds the state and the hold timer. Call `show(kind, value)` on every change and spread `hud` onto `<MacHud>`.

```tsx player.tsx
import { useEffect, useRef } from "react";
import { MacHud, useMacHud } from "@/components/eased/mac-hud/mac-hud";

export function Player() {
  const { hud, show } = useMacHud();
  const volume = useRef(0.5);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
      // One of sixteen segments per press, as on a Mac.
      const next = Math.round(volume.current * 16) + (e.key === "ArrowUp" ? 1 : -1);
      volume.current = Math.min(16, Math.max(0, next)) / 16;
      show("volume", volume.current);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [show]);

  return <MacHud {...hud} />;
}
```

`show` while the HUD is up updates it in place and restarts the 1.1-second hold, so a held key keeps it on screen until the last repeat.

## Examples

### Pill

The macOS 26 capsule, 12px in from the top-right corner. Put it under your own menu bar by rendering it in a positioned box that starts below the bar.

```tsx
<MacHud {...hud} variant="pill" />
```

### Muted

Pass `muted` with the volume: the speaker gets a slash and the bar empties, and the level comes back when you unmute.

```tsx
show("volume", volume, { muted: true });
```

### Inside a container

`position="absolute"` places it in the nearest positioned parent instead of the viewport. The classic HUD sits 140px above the bottom, or centres in a container too short for that.

```tsx
<div className="relative h-80">
  <MacHud {...hud} position="absolute" />
</div>
```

### In a desktop window

A HUD floats above every app, so in Tauri or Electron it lives in its own small window that is exactly the HUD's size. In both:

- The window draws real vibrancy, and the HUD takes `material="native"` and `position="none"`. A CSS blur can't see the desktop through a transparent window, so `"css"` there gives an unblurred tint.
- Make the page transparent with `html, body { background: transparent; }` (shadcn's `bg-background` on `body` covers the vibrancy) and call `useSystemAppearance()` from `@/components/eased/lib/mac`, so the glyphs follow the system's light or dark material.
- The window ignores the pointer, never takes focus, and sits centred 140pt above the bottom of the primary display.
- Hide the window in `onExitComplete`, or the empty vibrancy stays on screen after the HUD has left. Vibrancy belongs to the window, so it appears and disappears at once while the icon and bar animate.

For the pill, size the window 200×44 and place it 12pt below the menu bar at the right.

### In a Tauri app

Checked with Tauri 2.12. The window, in `tauri.conf.json`. `"focusable": false` matters: without it `show()` takes focus, and a tray panel that hides on blur closes. `windowEffects` only works with `transparent: true`; `macOSPrivateApi` is a no-op since 2.12.1.

```json src-tauri/tauri.conf.json
{
  "app": {
    "windows": [
      {
        "label": "hud",
        "url": "/hud",
        "width": 200,
        "height": 200,
        "visible": false,
        "transparent": true,
        "decorations": false,
        "focus": false,
        "focusable": false,
        "shadow": false,
        "resizable": false,
        "alwaysOnTop": true,
        "skipTaskbar": true,
        "visibleOnAllWorkspaces": true,
        "windowEffects": { "effects": ["hudWindow"], "state": "active", "radius": 18 }
      }
    ]
  }
}
```

A capability for the window, so its page may move, show, hide and click-through it:

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

The page listens for levels, sent from Rust with `app.emit_to("hud", "hud", level)` (from a global shortcut handler, say) or from another window with `emitTo`. It places the window on the primary display each time: the hidden window's own monitor is wherever it was last shown.

```tsx src/hud.tsx
import { listen } from "@tauri-apps/api/event";
import { PhysicalPosition } from "@tauri-apps/api/dpi";
import { getCurrentWindow, primaryMonitor } from "@tauri-apps/api/window";
import { useEffect } from "react";
import { useSystemAppearance } from "@/components/eased/lib/mac";
import { MacHud, useMacHud, type MacHudKind } from "@/components/eased/mac-hud/mac-hud";

type Level = { kind: MacHudKind; value: number; muted?: boolean };

export function HudWindow() {
  useSystemAppearance();
  const { hud, show } = useMacHud();

  useEffect(() => {
    const win = getCurrentWindow();
    win.setIgnoreCursorEvents(true);
    const off = listen<Level>("hud", async ({ payload }) => {
      const monitor = await primaryMonitor();
      if (monitor) {
        const size = 200 * monitor.scaleFactor;
        const x = monitor.position.x + (monitor.size.width - size) / 2;
        const y = monitor.position.y + monitor.size.height - size - 140 * monitor.scaleFactor;
        await win.setPosition(new PhysicalPosition(Math.round(x), Math.round(y)));
      }
      await win.show();
      show(payload.kind, payload.value, { muted: payload.muted });
    });
    return () => void off.then((unlisten) => unlisten());
  }, [show]);

  return <MacHud {...hud} position="none" material="native" onExitComplete={() => void getCurrentWindow().hide()} />;
}
```

### In an Electron app

The main process owns the window and sends it each level; `showInactive()` shows it without taking focus. Electron can't round vibrancy, and macOS gives a frameless window about 10pt corners, so pass `radius={10}` to match (the pill becomes a rounded rectangle there).

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

let hud;

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

  hud = new BrowserWindow({
    width: 200,
    height: 200,
    show: false,
    frame: false,
    transparent: true,
    backgroundColor: "#00000000",
    focusable: false,
    hasShadow: false,
    resizable: false,
    skipTaskbar: true,
    vibrancy: "hud",
    visualEffectState: "active",
    webPreferences: { preload: path.join(__dirname, "preload.cjs") },
  });
  hud.setAlwaysOnTop(true, "screen-saver");
  hud.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
  hud.setIgnoreMouseEvents(true);
  hud.loadFile(path.join(__dirname, "../dist/index.html"), { query: { view: "hud" } });
});

// Call from a globalShortcut handler, for example.
function showHud(level) {
  const { bounds } = screen.getPrimaryDisplay();
  hud.setPosition(Math.round(bounds.x + (bounds.width - 200) / 2), bounds.y + bounds.height - 200 - 140);
  if (!hud.isVisible()) hud.showInactive();
  hud.webContents.send("hud", level);
}

ipcMain.on("hud-closed", () => hud.hide());
```

```js electron/preload.cjs
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("hud", {
  onLevel: (callback) => {
    const listener = (_event, level) => callback(level);
    ipcRenderer.on("hud", listener);
    return () => ipcRenderer.off("hud", listener);
  },
  closed: () => ipcRenderer.send("hud-closed"),
});
```

```tsx src/hud.tsx
import { useEffect } from "react";
import { useSystemAppearance } from "@/components/eased/lib/mac";
import { MacHud, useMacHud, type MacHudKind } from "@/components/eased/mac-hud/mac-hud";

declare global {
  interface Window {
    hud: { onLevel: (callback: (level: { kind: MacHudKind; value: number; muted?: boolean }) => void) => () => void; closed: () => void };
  }
}

export function HudWindow() {
  useSystemAppearance();
  const { hud, show } = useMacHud();
  useEffect(() => window.hud.onLevel(({ kind, value, muted }) => show(kind, value, { muted })), [show]);
  return <MacHud {...hud} position="none" material="native" radius={10} onExitComplete={window.hud.closed} />;
}
```

## API reference

### MacHud

| Prop | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | | Show the HUD. Keep it mounted and toggle this so the exit animates; `useMacHud` drives it. |
| `kind` | `"volume" \| "brightness" \| "keyboard"` | | Which level it shows: picks the icon and the announcement. |
| `value` | `number` | | The level, 0 to 1, drawn in quarter segments (macOS's finest step, ⌥⇧ with a media key). |
| `muted` | `boolean` | `false` | Volume only: speaker with a slash, empty bar. |
| `variant` | `"classic" \| "pill"` | `"classic"` | The 200×200 square of macOS 15 and earlier, or the 200×44 capsule of macOS 26. |
| `position` | `"fixed" \| "absolute" \| "none"` | `"fixed"` | Against the viewport, in the nearest positioned parent, or in flow for you to place (a Tauri HUD window). |
| `material` | `"css" \| "native"` | `"css"` | `native` drops the CSS blur and tint for a window that draws real vibrancy. |
| `radius` | `number` | `18`, or a capsule for `pill` | Corner radius, px. Match a native window's corner: `10` in Electron. |
| `onExitComplete` | `() => void` | | Called when the exit animation has finished (not on a variant swap): hide a native HUD window here. |
| `labels` | `{ volume?, brightness?, keyboard?, muted?: string }` | `"Volume"`, `"Brightness"`, `"Keyboard brightness"`, `"muted"` | Translate the announcement. |
| `className` | `string` | `""` | Appended to the HUD's classes; position is kept. |

### useMacHud

`useMacHud({ duration? })` returns `{ hud, show, hide }`. `show(kind, value, { muted? })` opens or updates the HUD and restarts the hold; `hide()` closes it at once. `duration` is the hold after the last `show`, in ms (default `1100`). Spread `hud` onto `<MacHud>`.

### useSystemAppearance

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

### MacHudIcon

The HUD's glyphs on their own, for media keys and menus: `<MacHudIcon kind="volume" value={1} className="size-4" />`. Takes `kind`, `value`, `muted` and `className`; colour is `currentColor`.

## Accessibility

- A `role="status"` live region, mounted before the HUD first shows, announces each change ("Volume 60%", "Volume, muted"), so screen readers hear the level without focus moving.
- The visual HUD is `aria-hidden` and ignores the pointer: it never takes focus or blocks a click on what is under it.
- It never holds the only copy of information: the level lives in your app, and the HUD only reflects it.

## Motion

The HUD fades in on the enter curve while it scales from 0.96 on the snappy spring; the pill also drops 10px. It leaves in 150ms on the ease-in curve to 0.98 (the pill rises 6px), with no overshoot. While it is up, a new value never replays the entrance: classic segments snap in 100ms, the pill's bar slides on the smooth spring, and icon changes (a wave, the mute slash, longer sun rays) fade and un-blur on the text-swap tokens; a new kind swaps the whole glyph. Changing `variant` while it is up swaps one HUD for the other, without a morph. Reduced motion keeps 100ms opacity fades only and moves the bar at once.

## Notes for AI

Use Mac HUD for transient feedback on a level changed by a shortcut or media key in a Mac-style app. Call `show` from `useMacHud` on every change, including repeats; render one HUD per window with `{...hud}` spread onto it. In Tauri or Electron, render it with `material="native"` and `position="none"` in a 200×200 vibrancy window that ignores the pointer and can't take focus, with a transparent page and `useSystemAppearance()`; hide the window in `onExitComplete`.

Do not unmount it to hide it, do not add a timer of your own (the hook owns the 1.1-second hold), and do not put controls in it: it is click-through. Do not give it a drop shadow, or a different radius except to match a native window's corner (`radius={10}` in Electron).

## Related

- [Undo Pill](/components/undo-pill): a transient pill people can act on.
- [Segmented](/components/segmented): the classic and pill switch in the demo.
- [Button](/components/button): the media keys in the demo.

## Example

`components/eased/mac-hud/mac-hud-demo.tsx`

```tsx
"use client";

import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { Button } from "../button/button";
import { MacDesktop } from "../lib/mac-desktop";
import { Segmented } from "../segmented/segmented";
import { MacHud, MacHudIcon, useMacHud, type MacHudKind, type MacHudVariant } from "./mac-hud";

type Levels = Record<MacHudKind, number> & { muted: boolean };

const VARIANTS = [
  { value: "classic", label: "Classic" },
  { value: "pill", label: "Pill" },
] as const;

// macOS moves a level one segment (1/16) per press, or a quarter segment with ⌥⇧ held.
const QUARTERS = 64;
// Close to macOS's default key repeat: a pause, then about twelve steps a second.
const DELAY = 400;
const REPEAT = 80;

/*
 * The keys sit 6px apart, so a mouse gets the 32px key alone. On touch they take the 44px
 * target: neighbours' areas meet only in the gap, never over each other's key.
 */
const TOUCH_TARGET = "pointer-coarse:hit-area";

const ARROWS: Record<string, [MacHudKind, 1 | -1]> = {
  ArrowUp: ["volume", 1],
  ArrowDown: ["volume", -1],
  ArrowRight: ["brightness", 1],
  ArrowLeft: ["brightness", -1],
};

export function MacHudDemo() {
  const [variant, setVariant] = useState<MacHudVariant>("classic");
  const { hud, show } = useMacHud();
  const levels = useRef<Levels>({ volume: 0.5, brightness: 0.75, keyboard: 0.5, muted: false });
  const hold = useHold();

  const showLevel = useCallback(
    (kind: MacHudKind) => {
      const l = levels.current;
      show(kind, l[kind], { muted: kind === "volume" && l.muted });
    },
    [show],
  );

  // Pressing a volume key unmutes, as on a Mac.
  const nudge = useCallback(
    (kind: MacHudKind, dir: 1 | -1, fine = false) => {
      const l = levels.current;
      l[kind] = Math.min(QUARTERS, Math.max(0, Math.round(l[kind] * QUARTERS) + dir * (fine ? 1 : 4))) / QUARTERS;
      if (kind === "volume") l.muted = false;
      showLevel(kind);
    },
    [showLevel],
  );

  const mute = () => {
    levels.current.muted = !levels.current.muted;
    showLevel("volume");
  };

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.metaKey || e.ctrlKey) return;
    const arrow = ARROWS[e.key];
    if (arrow) {
      e.preventDefault();
      nudge(arrow[0], arrow[1], e.altKey && e.shiftKey);
    } else if (e.key.toLowerCase() === "m" && !e.repeat) {
      mute();
    }
  };

  const key = (kind: MacHudKind, dir: 1 | -1) => (fine: boolean) => nudge(kind, dir, fine);

  return (
    <MacDesktop
      app="Music"
      menus={["File", "Edit", "Song", "View"]}
      footer={
        <div className="flex shrink-0 items-center justify-between gap-3 border-t border-line-subtle px-(--pad) py-3">
          <Segmented
            aria-label="HUD style"
            // Segments are 26px tall; on touch each gets the 44px target without growing.
            className="pointer-coarse:[&>button]:hit-area"
            options={VARIANTS}
            value={variant}
            onValueChange={(v) => {
              setVariant(v);
              showLevel(hud.kind);
            }}
          />
          <div className="flex items-center gap-2" onKeyDown={onKeyDown}>
            <KeyGroup label="Display brightness">
              <MediaKey label="Brightness down" hold={hold} onPress={key("brightness", -1)}>
                <MacHudIcon kind="brightness" value={0} className="size-4" />
              </MediaKey>
              <MediaKey label="Brightness up" hold={hold} onPress={key("brightness", 1)}>
                <MacHudIcon kind="brightness" value={1} className="size-4" />
              </MediaKey>
            </KeyGroup>
            <KeyGroup label="Keyboard brightness" className="@max-sm:hidden">
              <MediaKey label="Keyboard brightness down" hold={hold} onPress={key("keyboard", -1)}>
                <MacHudIcon kind="keyboard" value={0} className="size-4" />
              </MediaKey>
              <MediaKey label="Keyboard brightness up" hold={hold} onPress={key("keyboard", 1)}>
                <MacHudIcon kind="keyboard" value={1} className="size-4" />
              </MediaKey>
            </KeyGroup>
            <KeyGroup label="Volume">
              <Button variant="secondary" size="icon-sm" hitArea={false} className={TOUCH_TARGET} aria-label="Mute" onClick={mute}>
                <MacHudIcon kind="volume" value={0} muted className="size-4" />
              </Button>
              <MediaKey label="Volume down" hold={hold} onPress={key("volume", -1)}>
                <MacHudIcon kind="volume" value={0.3} className="size-4" />
              </MediaKey>
              <MediaKey label="Volume up" hold={hold} onPress={key("volume", 1)}>
                <MacHudIcon kind="volume" value={1} className="size-4" />
              </MediaKey>
            </KeyGroup>
          </div>
        </div>
      }
    >
      {/* The desktop under the menu bar: the pill sits 12px below it, the classic HUD centres. */}
      <div
        tabIndex={0}
        role="group"
        aria-label="Desktop. Arrow keys change volume and brightness, M mutes."
        onKeyDown={onKeyDown}
        className="absolute inset-0 focus-ring [outline-offset:-2px]"
      >
        <MacHud {...hud} variant={variant} position="absolute" />
      </div>
    </MacDesktop>
  );
}

function KeyGroup({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
  return (
    <div role="group" aria-label={label} className={`flex gap-1.5 ${className}`}>
      {children}
    </div>
  );
}

type Hold = ReturnType<typeof useHold>;

/** A media key: fires on press, then repeats while held. Enter repeats through the keyboard's own auto-repeat. */
function MediaKey({ label, hold, onPress, children }: { label: string; hold: Hold; onPress: (fine: boolean) => void; children: ReactNode }) {
  return (
    <Button
      variant="secondary"
      size="icon-sm"
      hitArea={false}
      className={TOUCH_TARGET}
      aria-label={label}
      onPointerDown={(e) => {
        if (e.button !== 0) return;
        const fine = e.altKey && e.shiftKey;
        hold.start(() => onPress(fine));
      }}
      onPointerUp={hold.stop}
      onPointerLeave={hold.stop}
      onPointerCancel={hold.stop}
      // Pointer presses already fired on pointer down; detail 0 is a keyboard click.
      onClick={(e) => {
        if (e.detail === 0) onPress(e.altKey && e.shiftKey);
      }}
      onContextMenu={(e) => e.preventDefault()}
    >
      {children}
    </Button>
  );
}

/** Fire once now, again after DELAY, then every REPEAT ms until stopped. */
function useHold() {
  const timer = useRef<number | undefined>(undefined);
  const stop = useCallback(() => {
    window.clearTimeout(timer.current);
    window.clearInterval(timer.current);
  }, []);
  const start = useCallback(
    (action: () => void) => {
      stop();
      action();
      timer.current = window.setTimeout(() => {
        timer.current = window.setInterval(action, REPEAT);
      }, DELAY);
    },
    [stop],
  );
  useEffect(() => {
    window.addEventListener("blur", stop);
    return () => {
      stop();
      window.removeEventListener("blur", stop);
    };
  }, [stop]);
  return { start, stop };
}
```
