Compass menu
A radial/compass action menu: four items placed above/after/below/before a hub with data-⚓️-side, opening out of the hub with data-⚓️-morph-from.
<main class="page">
<header class="intro">
<h1>Compass menu</h1>
<p>
Four items sit <code>above</code>, <code>after</code>, <code>below</code> and <code>before</code>
the hub via <code>data-⚓️-side</code>, and open out of it with <code>data-⚓️-morph-from</code>.
Pick one and it moves to the top: the menu reorders by changing side values.
Arrow keys go in the direction of the side.
</p>
<label class="xray"><input type="checkbox" id="xray"> X-ray</label>
</header>
<section class="stage" data-⚓️-root>
<button class="hub" id="hub" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="menu" data-⚓️-name="--hub">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
<span class="visually-hidden">Actions</span>
</button>
<ul class="menu" id="menu" role="menu" aria-label="Actions">
<li class="item" role="none" hidden data-⚓️-to="--hub" data-⚓️-side="above" data-⚓️-align-x="center" data-⚓️-tween data-⚓️-morph-from data-⚓️-transition="morph">
<button role="menuitem" type="button" data-action="Copied">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>
<span>Copy</span>
</button>
</li>
<li class="item" role="none" hidden data-⚓️-to="--hub" data-⚓️-side="after" data-⚓️-align-y="center" data-⚓️-tween data-⚓️-morph-from data-⚓️-transition="morph">
<button role="menuitem" type="button" data-action="Shared">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.2 10.9l7.6-3.8M8.2 13.1l7.6 3.8"/></svg>
<span>Share</span>
</button>
</li>
<li class="item" role="none" hidden data-⚓️-to="--hub" data-⚓️-side="below" data-⚓️-align-x="center" data-⚓️-tween data-⚓️-morph-from data-⚓️-transition="morph">
<button role="menuitem" type="button" data-action="Pinned">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 17v5M8 3h8l-1 6 3 3v2H6v-2l3-3z"/></svg>
<span>Pin</span>
</button>
</li>
<li class="item" role="none" hidden data-⚓️-to="--hub" data-⚓️-side="before" data-⚓️-align-y="center" data-⚓️-tween data-⚓️-morph-from data-⚓️-transition="morph">
<button role="menuitem" type="button" data-action="Deleted" class="danger">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>
<span>Delete</span>
</button>
</li>
</ul>
<p class="status" id="status" role="status"></p>
</section>
</main>/* Demo styling only. Each item's place around the hub is its data-⚓️-side
value (plus data-⚓️-align-x/y to centre it on the cross axis). */
:root {
color-scheme: dark;
--bg: oklch(15% 0.02 285);
--surface: oklch(23% 0.03 285);
--line: oklch(38% 0.05 285);
--text: oklch(94% 0.01 285);
--muted: oklch(70% 0.03 285);
--accent: oklch(78% 0.15 160);
--danger: oklch(70% 0.18 25);
--gap: 0.6rem;
background:
radial-gradient(circle at 50% 60%, oklch(24% 0.06 285), transparent 60%),
var(--bg);
color: var(--text);
font: 400 1rem/1.45 system-ui, sans-serif;
}
*, ::before, ::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
}
.page {
min-height: 100dvh;
display: grid;
grid-template-rows: auto 1fr;
padding: clamp(1rem, 4vw, 2.5rem);
gap: 1rem;
}
.intro { max-inline-size: 62ch; }
h1 { margin: 0 0 0.3rem; font-size: 1.4rem; letter-spacing: -0.01em; }
.intro p { margin: 0 0 0.6rem; color: var(--muted); font-size: 0.92rem; }
code { font: 0.85em ui-monospace, monospace; color: oklch(85% 0.1 160); }
.xray { display: inline-flex; gap: 0.4rem; align-items: center; color: var(--muted); font-size: 0.9rem; cursor: pointer; }
.stage {
display: grid;
place-items: center;
min-block-size: 22rem;
}
.menu { grid-area: 1 / 1; margin: 0; padding: 0; list-style: none; }
.item { view-transition-name: match-element; }
/* while a view transition reorders the items, let it do all the moving */
.reordering .item { transition: none; }
button {
font: inherit;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
svg {
inline-size: 1.4rem;
block-size: 1.4rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.hub {
grid-area: 1 / 1;
display: grid;
place-items: center;
inline-size: 4.25rem;
aspect-ratio: 1;
border-radius: 50%;
border: 1px solid oklch(from var(--accent) 60% c h);
background: oklch(from var(--accent) 35% calc(c * 0.6) h);
color: var(--text);
box-shadow: 0 0.5rem 2rem oklch(from var(--accent) 40% c h / 0.35);
transition: scale 0.15s;
}
.hub svg { inline-size: 1.8rem; block-size: 1.8rem; transition: rotate 0.35s cubic-bezier(0.25, 1, 0.5, 1); }
.hub:active { scale: 0.94; }
/* rotate the glyph, not the hub: the hub is the anchor, and Chrome anchors to its transformed box */
.hub[aria-expanded="true"] svg { rotate: 45deg; }
/* The item box carries the gap as padding, so the same gap applies on
whichever side it sits and nothing side-specific has to animate. */
.item { padding: var(--gap); }
.item button {
display: grid;
place-items: center;
gap: 0.15rem;
inline-size: 4.75rem;
aspect-ratio: 1;
border-radius: 1.25rem;
border: 1px solid var(--line);
background: var(--surface);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
box-shadow: 0 0.4rem 1.2rem oklch(0% 0 0 / 0.35);
}
.item button:hover { border-color: var(--accent); color: var(--accent); }
.item button.danger:hover { border-color: var(--danger); color: var(--danger); }
/* the item currently on top is the most recently used one */
.item[data-⚓️-side="above"] button { border-color: oklch(from var(--accent) 55% calc(c * 0.7) h); }
.status {
grid-area: 1 / 1;
align-self: end;
margin: 0;
min-block-size: 1.5em;
color: var(--accent);
font-weight: 600;
}
/* reordering across axes uses a view transition (see script.js) */
::view-transition-group(*) { animation-duration: 0.4s; animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1); }
.visually-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* X-ray: outline the anchor and label each item with its live side */
body:has(#xray:checked) [data-⚓️-name] { outline: 1px dashed oklch(82% 0.15 90); outline-offset: 4px; }
body:has(#xray:checked) .item { outline: 1px dashed oklch(82% 0.15 90 / 0.5); }
body:has(#xray:checked) .item::after {
content: attr(data-⚓️-side);
position: absolute;
inset-block-start: 0;
inset-inline-start: 50%;
translate: -50% -50%;
padding: 0 0.3rem;
border-radius: 0.3rem;
background: oklch(82% 0.15 90);
color: oklch(20% 0.02 90);
font: 700 0.6rem/1.5 ui-monospace, monospace;
}
@media (prefers-reduced-motion: reduce) {
.hub, .hub svg { transition: none; }
::view-transition-group(*) { animation-duration: 0s; }
}// State only: open/closed, and which item holds which side. Positions come
// from data-⚓️-side (+ align-x/align-y to centre on the cross axis).
const hub = document.getElementById("hub");
const items = [...document.querySelectorAll(".item")];
const status = document.getElementById("status");
const SIDES = ["above", "after", "below", "before"]; // clockwise (LTR)
const side = (el) => el.getAttribute("data-⚓️-side");
// most-recently-used first: index 0 sits above, then clockwise
let order = SIDES.map((s) => items.find((el) => side(el) === s));
function applyOrder() {
order.forEach((el, i) => {
const s = SIDES[i];
el.setAttribute("data-⚓️-side", s);
const vertical = s === "above" || s === "below";
// centre on the cross axis
el.setAttribute(`data-⚓️-align-${vertical ? "x" : "y"}`, "center");
el.removeAttribute(`data-⚓️-align-${vertical ? "y" : "x"}`);
});
}
function setOpen(open, { focus = true } = {}) {
hub.setAttribute("aria-expanded", String(open));
for (const el of items) el.hidden = !open;
if (open && focus) order[0].querySelector("button").focus();
if (!open && focus) hub.focus();
}
const isOpen = () => hub.getAttribute("aria-expanded") === "true";
hub.addEventListener("click", () => setOpen(!isOpen(), { focus: false }));
for (const el of items) {
el.querySelector("button").addEventListener("click", (e) => {
const btn = e.currentTarget;
status.textContent = `${btn.dataset.action}.`;
if (order[0] === el) return;
order = [el, ...order.filter((x) => x !== el)];
// Changing sides across axes (above -> after) swaps which inset is set,
// which CSS can't interpolate, so a view transition animates the snap.
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!document.startViewTransition || reduce) return applyOrder();
document.documentElement.classList.add("reordering");
const vt = document.startViewTransition(applyOrder);
vt.finished.finally(() => {
document.documentElement.classList.remove("reordering");
btn.focus();
});
});
}
// Arrow keys move in the direction of the side (mirrored in RTL).
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && isOpen()) return setOpen(false);
if (!isOpen()) return;
const rtl = getComputedStyle(hub).direction === "rtl";
const map = { ArrowUp: "above", ArrowDown: "below", ArrowLeft: rtl ? "after" : "before", ArrowRight: rtl ? "before" : "after" };
const want = map[e.key];
if (!want) return;
e.preventDefault();
items.find((el) => side(el) === want)?.querySelector("button").focus();
});data-⚓️-align-xdata-⚓️-align-ydata-⚓️-morph-fromdata-⚓️-namedata-⚓️-rootdata-⚓️-sidedata-⚓️-todata-⚓️-transitiondata-⚓️-tween