Astro UI
Glide Menu.
One highlight that glides to the hovered or focused row.
NavigationAtomNo dependencies Beautiful UI, MIT
Install
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import GlideMenu from "../components/viewership/glide-menu/GlideMenu.astro"; --- <GlideMenu />
Props 9
| 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. |
Source 2 files
---
/**
* 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> /**
* 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();
};
} Ported from Beautiful UI, MIT. Notices.
Used by Approval CardPrompt BarRecords TableSidebar NavSearchFine-tune Card