# Glide Menu

> One highlight that glides to the hovered or focused row.

- **Slug:** `glide-menu`
- **Kind:** atom · **Category:** Navigation
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/GlideMenu.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/glide-menu
- **All files in one bundle:** https://viewership.ai/ui/components/glide-menu.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/glide-menu.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy these files into `src/components/viewership/glide-menu/`:

   - `GlideMenu.astro` (raw: https://viewership.ai/ui/raw/glide-menu/GlideMenu.astro.txt)
   - `glide-menu.ts` (raw: https://viewership.ai/ui/raw/glide-menu/glide-menu.ts.txt)

npm packages: none.

## Usage

```astro
---
import GlideMenu from "../components/viewership/glide-menu/GlideMenu.astro";
---

<GlideMenu />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import GlideMenu from "./GlideMenu.astro";

interface Props {
  variant?: string;
}
const { variant = "Menu" } = Astro.props;

const actions = [
  { label: "Run report", hint: "R", icon: '<polygon points="6 3 20 12 6 21 6 3"/>' },
  { label: "Add edit notes", hint: "C", icon: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>' },
  { label: "Duplicate draft", hint: "D", icon: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>' },
  { label: "Export report", hint: "E", icon: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>' },
];

const nav = [
  { label: "Content drafts", count: "14", current: true, icon: '<polygon points="6 3 20 12 6 21 6 3"/>' },
  { label: "Reports", count: "6", icon: '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/>' },
  { label: "Citations", count: "23", icon: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>' },
  { label: "Threads", icon: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>' },
  { label: "Approvals", icon: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>' },
];
---

{variant === "Menu" && (
  <div class="popover">
    <p class="eyebrow">Draft 214 · Spring Trails</p>
    <GlideMenu class="list" arrowKeys aria-label="Draft actions" role="group">
      {actions.map((a) => (
        <button type="button" class="row" data-menu-row>
          <svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" set:html={a.icon} />
          <span class="demo-label">{a.label}</span>
          <kbd class="hint">{a.hint}</kbd>
        </button>
      ))}
      <div class="sep" role="separator"></div>
      <button type="button" class="row row--danger" data-menu-row>
        <svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <rect width="20" height="5" x="2" y="3" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" /><path d="M10 12h4" />
        </svg>
        <span class="demo-label">Archive draft</span>
      </button>
    </GlideMenu>
  </div>
)}

{variant === "Navigation" && (
  <div class="sidebar" data-glide-nav-demo>
    <p class="eyebrow">Northwind Outdoor</p>
    <GlideMenu as="nav" class="list" aria-label="Marketing" highlightBg="var(--vs-hover-2)" highlightRadius="7px" arrowKeys>
      {nav.map((n) => (
        <a href={`#${n.label.toLowerCase().replace(/\W+/g, "-")}`} class="row nav-row" data-menu-row aria-current={n.current ? "page" : undefined}>
          <svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" set:html={n.icon} />
          <span class="demo-label">{n.label}</span>
          {n.count && <span class="count">{n.count}</span>}
        </a>
      ))}
    </GlideMenu>
  </div>
)}

<script>
  // Demo only: keep clicks on the page and move aria-current to the clicked row.
  const boot = () =>
    document.querySelectorAll<HTMLElement>("[data-glide-nav-demo]:not([data-vs-ready])").forEach((demo) => {
      demo.dataset.vsReady = "";
      demo.addEventListener("click", (event) => {
        const link = (event.target as Element).closest<HTMLAnchorElement>("a[data-menu-row]");
        if (!link) return;
        event.preventDefault();
        demo.querySelectorAll("a[data-menu-row]").forEach((a) => a.removeAttribute("aria-current"));
        link.setAttribute("aria-current", "page");
      });
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .popover,
  .sidebar {
    margin: 0 auto;
    padding: 6px;
    border-radius: var(--vs-radius-window);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-overlay);
  }
  .popover {
    width: 248px;
  }
  .sidebar {
    width: 224px;
    box-shadow: var(--vs-shadow-card);
  }
  .eyebrow {
    margin: 0;
    padding: 2px 8px 4px;
    font-size: 11.5px;
    font-weight: 500;
    color: var(--vs-ink-3);
  }
  .list {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .row {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    height: 32px;
    padding: 0 8px;
    border: none;
    border-radius: 8px;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    font-weight: 500;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
    transition: transform var(--vs-duration-fast) ease-out;
  }
  .row:active {
    transform: scale(0.98);
  }
  .row:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .icon {
    flex-shrink: 0;
    color: var(--vs-ink-2);
  }
  .demo-label {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .hint {
    font-family: var(--vs-font-mono);
    font-size: 11px;
    color: var(--vs-ink-3);
  }
  .row--danger,
  .row--danger .icon {
    color: var(--vs-red);
  }
  .sep {
    height: 1px;
    margin: 4px 0;
    background: var(--vs-line);
  }

  .nav-row {
    font-size: 14px;
    color: var(--vs-ink-2);
  }
  .nav-row[aria-current="page"] {
    color: var(--vs-ink);
    background: var(--vs-hover-2);
  }
  .nav-row[aria-current="page"] .icon {
    color: var(--vs-ink);
  }
  /* While the highlight is elsewhere, the active row drops its own fill. */
  .list[data-glide-active] .nav-row[aria-current="page"]:not([data-glide-current]) {
    background: transparent;
  }
  .count {
    font-size: 12px;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    color: var(--vs-ink-3);
  }

  @media (prefers-reduced-motion: reduce) {
    .row:active {
      transform: none;
    }
  }
</style>
```

## Variants

Demo variants: `Menu`, `Navigation`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `as` | `"div" \| "nav"` | `"div"` | Root element. You set the role. |
| `rowSelector` | `string` | `"[data-menu-row]"` | Rows the highlight snaps to. |
| `highlightBg` | `string` | `"var(--vs-hover)"` | Highlight background. |
| `highlightRadius` | `string` | `"8px"` | Highlight corner radius. |
| `highlightInset` | `string` | `"0px"` | Horizontal inset; negative bleeds past rows (sets --vs-glide-inset). |
| `highlightClass` | `string` |  | Extra class on the highlight. Style with :global(). |
| `highlightStyle` | `string` |  | Extra highlight inline style. |
| `arrowKeys` | `boolean` | `false` | Up, Down, Home, End move focus between rows (roving tabindex). |
| `initGlideMenu(el, opts)` | `(el: HTMLElement, opts?: GlideMenuOptions) => () => void` |  | From glide-menu.ts: wire a script-built menu. Returns a teardown. |

## Used by

[Approval Card](https://viewership.ai/ui/components/approval-card.md), [Prompt Bar](https://viewership.ai/ui/components/prompt-bar.md), [Records Table](https://viewership.ai/ui/components/records-table.md), [Sidebar Nav](https://viewership.ai/ui/components/sidebar-nav.md), [Search](https://viewership.ai/ui/components/search.md), [Fine-tune Card](https://viewership.ai/ui/components/fine-tune-card.md)

## Source

### `src/components/viewership/glide-menu/GlideMenu.astro`

```astro
---
/**
 * GlideMenu: a container whose single highlight glides to the hovered or
 * focused row.
 *
 * Ported from Beautiful UI `components/primitives/GlideMenu.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * Mark rows with `data-menu-row` (or pass `rowSelector`). The highlight
 * follows pointer and keyboard focus; set `arrowKeys` for Up/Down/Home/End
 * with a roving tabindex. Roles (menu, listbox, nav) are up to you.
 *
 * Style the highlight with `highlightBg` / `highlightRadius` /
 * `highlightInset` (or `--vs-glide-*`). A `highlightClass` needs
 * `:global(...)`. While showing, the root has `data-glide-active` and the
 * row has `data-glide-current`.
 *
 * For menus built by script, use `initGlideMenu` from `./glide-menu`.
 *
 * @example
 * <GlideMenu class="list" highlightBg="var(--vs-field)" highlightRadius="6px" arrowKeys>
 *   <button data-menu-row>Run report</button>
 *   <button data-menu-row>Save draft</button>
 * </GlideMenu>
 */
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** Root element. */
  as?: "div" | "nav";
  /** Selector for rows the highlight snaps to. */
  rowSelector?: string;
  /** Highlight background. */
  highlightBg?: string;
  /** Highlight corner radius. */
  highlightRadius?: string;
  /** Horizontal inset of the highlight; negative values bleed outward. */
  highlightInset?: string;
  /** Extra class on the highlight (style it with :global). */
  highlightClass?: string;
  /** Extra inline style on the highlight. */
  highlightStyle?: string;
  /** Arrow keys / Home / End move focus between rows (roving tabindex). */
  arrowKeys?: boolean;
};

const {
  as: Tag = "div",
  rowSelector = "[data-menu-row]",
  highlightBg,
  highlightRadius,
  highlightInset,
  highlightClass,
  highlightStyle,
  arrowKeys = false,
  class: className,
  style,
  ...rest
} = Astro.props;

const vars = [
  highlightBg && `--vs-glide-bg: ${highlightBg}`,
  highlightRadius && `--vs-glide-radius: ${highlightRadius}`,
  highlightInset && `--vs-glide-inset: ${highlightInset}`,
]
  .filter(Boolean)
  .join("; ");
const rootStyle = [vars, typeof style === "string" ? style : ""].filter(Boolean).join("; ") || undefined;
---

<Tag
  class:list={["vs-glide-menu", className]}
  data-row-selector={rowSelector === "[data-menu-row]" ? undefined : rowSelector}
  data-arrow-keys={arrowKeys ? "" : undefined}
  style={rootStyle}
  {...rest}
>
  <span
    class:list={["vs-glide-menu__highlight", highlightClass]}
    style={highlightStyle}
    aria-hidden="true"
    data-glide-highlight
  ></span>
  <slot />
</Tag>

<script>
  import { initGlideMenu } from "./glide-menu";

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-glide-menu:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      initGlideMenu(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-glide-menu {
    position: relative;
  }
  .vs-glide-menu__highlight {
    position: absolute;
    top: 0;
    left: var(--vs-glide-inset, 0px);
    right: var(--vs-glide-inset, 0px);
    height: 0;
    opacity: 0;
    pointer-events: none;
    border-radius: var(--vs-glide-radius, 8px);
    background: var(--vs-glide-bg, var(--vs-hover));
  }
  /* Rows paint above the highlight. Zero specificity so consumers can override. */
  :global(:where(.vs-glide-menu [data-menu-row])) {
    position: relative;
  }

  @media (forced-colors: active) {
    .vs-glide-menu__highlight {
      outline: 1px solid Highlight;
      background: transparent;
    }
  }
</style>
```

### `src/components/viewership/glide-menu/glide-menu.ts`

```ts
/**
 * glide-menu: the behaviour behind <GlideMenu>.
 *
 * Ported from Beautiful UI `components/primitives/GlideMenu.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * One highlight glides between a container's rows, following pointer and
 * keyboard focus. GlideMenu.astro calls this; call it yourself for menus
 * built at runtime:
 *
 *   const stop = initGlideMenu(menuEl, { arrowKeys: true });
 *   // later: stop();
 *
 * A highlight element is created if missing. Style it with custom
 * properties on the container:
 *   --vs-glide-bg      (default var(--vs-hover))
 *   --vs-glide-radius  (default 8px)
 *   --vs-glide-inset   horizontal inset; negative bleeds outward (default 0)
 *
 * While showing, the container has `data-glide-active` and the row has
 * `data-glide-current`.
 */

export interface GlideMenuOptions {
  /** Selector for rows the highlight snaps to. Default `[data-menu-row]`, or the container's `data-row-selector`. */
  rowSelector?: string;
  /**
   * Arrow keys / Home / End move focus between rows, with a roving tabindex so
   * Tab enters and leaves the group in one stop. Default: the container's
   * `data-arrow-keys` attribute.
   */
  arrowKeys?: boolean;
}

const EASE = "var(--vs-ease-out, cubic-bezier(0.23, 1, 0.32, 1))";
const TRANSITION = `top 220ms ${EASE}, height 220ms ${EASE}, opacity 150ms ease`;

function ensureHighlight(root: HTMLElement): HTMLElement {
  const existing = root.querySelector<HTMLElement>(":scope > [data-glide-highlight]");
  if (existing) return existing;
  const hl = document.createElement("span");
  hl.setAttribute("aria-hidden", "true");
  hl.setAttribute("data-glide-highlight", "");
  Object.assign(hl.style, {
    position: "absolute",
    pointerEvents: "none",
    left: "var(--vs-glide-inset, 0px)",
    right: "var(--vs-glide-inset, 0px)",
    top: "0px",
    height: "0px",
    opacity: "0",
    borderRadius: "var(--vs-glide-radius, 8px)",
    background: "var(--vs-glide-bg, var(--vs-hover))",
  } satisfies Partial<CSSStyleDeclaration>);
  root.prepend(hl);
  return hl;
}

/** Wire one container. Returns a teardown function. */
export function initGlideMenu(root: HTMLElement, options: GlideMenuOptions = {}): () => void {
  const rowSelector = options.rowSelector ?? root.dataset.rowSelector ?? "[data-menu-row]";
  const arrowKeys = options.arrowKeys ?? root.hasAttribute("data-arrow-keys");
  const hl = ensureHighlight(root);
  const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");

  if (getComputedStyle(root).position === "static") root.style.position = "relative";

  let current: HTMLElement | null = null;
  let placedOnce = false;

  const rows = () =>
    Array.from(root.querySelectorAll<HTMLElement>(rowSelector)).filter(
      (row) => row !== root && !isDisabled(row) && row.getClientRects().length > 0,
    );

  const isDisabled = (row: HTMLElement) => row.matches(":disabled, [aria-disabled='true'], [inert] *");

  const rowFrom = (target: EventTarget | null): HTMLElement | null => {
    if (!(target instanceof Element)) return null;
    const row = target.closest<HTMLElement>(rowSelector);
    return row && row !== root && root.contains(row) ? row : null;
  };

  const place = (row: HTMLElement) => {
    const rowRect = row.getBoundingClientRect();
    const rootRect = root.getBoundingClientRect();
    hl.style.top = `${rowRect.top - rootRect.top - root.clientTop + root.scrollTop}px`;
    hl.style.height = `${rowRect.height}px`;
  };

  const moveTo = (row: HTMLElement) => {
    if (row === current || isDisabled(row)) return;
    // Rows must paint above the absolutely positioned highlight.
    if (getComputedStyle(row).position === "static") row.style.position = "relative";
    // The first placement jumps into position; later ones glide.
    hl.style.transition = reduce.matches ? "none" : placedOnce ? TRANSITION : "opacity 150ms ease";
    place(row);
    placedOnce = true;
    hl.style.opacity = "1";
    current?.removeAttribute("data-glide-current");
    current = row;
    row.setAttribute("data-glide-current", "");
    root.setAttribute("data-glide-active", "");
  };

  const hide = () => {
    hl.style.opacity = "0";
    current?.removeAttribute("data-glide-current");
    current = null;
    root.removeAttribute("data-glide-active");
  };

  const focusedRow = () => {
    const active = document.activeElement;
    return active instanceof HTMLElement && active.matches(":focus-visible") ? rowFrom(active) : null;
  };

  const onMouseOver = (event: MouseEvent) => {
    const row = rowFrom(event.target);
    if (row) moveTo(row);
  };
  const onMouseLeave = () => {
    // Hand the highlight back to a keyboard-focused row instead of hiding it.
    const row = focusedRow();
    if (row) moveTo(row);
    else hide();
  };
  const onFocusIn = (event: FocusEvent) => {
    const row = rowFrom(event.target);
    if (!row) return;
    moveTo(row);
    if (arrowKeys) setRoving(row);
  };
  const onFocusOut = (event: FocusEvent) => {
    const next = event.relatedTarget;
    if (next instanceof Node && root.contains(next)) return;
    if (!root.matches(":hover")) hide();
  };

  const setRoving = (active: HTMLElement) => {
    for (const row of root.querySelectorAll<HTMLElement>(rowSelector)) {
      row.tabIndex = row === active ? 0 : -1;
    }
  };

  const onKeyDown = (event: KeyboardEvent) => {
    if (event.altKey || event.ctrlKey || event.metaKey) return;
    const from = rowFrom(event.target);
    if (!from) return;
    const list = rows();
    const i = list.indexOf(from);
    let next: HTMLElement | undefined;
    if (event.key === "ArrowDown") next = list[(i + 1) % list.length];
    else if (event.key === "ArrowUp") next = list[(i - 1 + list.length) % list.length];
    else if (event.key === "Home") next = list[0];
    else if (event.key === "End") next = list[list.length - 1];
    if (!next) return;
    event.preventDefault();
    next.focus();
  };

  if (arrowKeys) {
    const list = rows();
    const initial =
      list.find((row) => row.matches("[aria-current]:not([aria-current='false']), [aria-checked='true'], [aria-selected='true']")) ??
      list[0];
    if (initial) setRoving(initial);
  }

  // Rows can reflow (fonts loading, a sidebar collapsing): keep the highlight on its row.
  const resize = new ResizeObserver(() => {
    if (current?.isConnected) place(current);
  });
  resize.observe(root);

  root.addEventListener("mouseover", onMouseOver);
  root.addEventListener("mouseleave", onMouseLeave);
  root.addEventListener("focusin", onFocusIn);
  root.addEventListener("focusout", onFocusOut);
  if (arrowKeys) root.addEventListener("keydown", onKeyDown);

  return () => {
    resize.disconnect();
    root.removeEventListener("mouseover", onMouseOver);
    root.removeEventListener("mouseleave", onMouseLeave);
    root.removeEventListener("focusin", onFocusIn);
    root.removeEventListener("focusout", onFocusOut);
    root.removeEventListener("keydown", onKeyDown);
    hide();
  };
}
```
