Navigation
Action Menu
A softly unfolding creation menu with tactile icon tiles, clear choices, and keyboard-friendly dismissal.
#menu#disclosure#motion#interaction
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/action-menu.json"use client";
import { useEffect, useId, useRef, useState } from "react";
const css = String.raw`
.am-root {
--am-bg: #fbfaf7;
--am-ink: #282a28;
--am-muted: #6c706b;
--am-line: #e6e5dd;
--am-hover: #f0f1e9;
width: min(290px, 100%);
color: var(--am-ink);
font:
14px/1.4 system-ui,
sans-serif;
}
.am-root * {
box-sizing: border-box;
}
:root[data-theme="dark"] .am-root {
--am-bg: #232723;
--am-ink: #f2f2eb;
--am-muted: #afb7ac;
--am-line: #3b4239;
--am-hover: #333b30;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .am-root {
--am-bg: #232723;
--am-ink: #f2f2eb;
--am-muted: #afb7ac;
--am-line: #3b4239;
--am-hover: #333b30;
}
}
.am-trigger {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--am-line);
border-radius: 18px;
background: var(--am-bg);
color: var(--am-ink);
font: inherit;
cursor: pointer;
box-shadow:
0 5px 14px -10px #0007,
inset 0 1px #ffffff12;
text-align: left;
transition:
box-shadow 0.2s,
background 0.2s;
}
.am-trigger:hover {
background: var(--am-hover);
box-shadow: 0 8px 22px -12px #0007;
}
.am-plus {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: none;
background: #d9f69c;
color: #263316;
border-radius: 11px;
}
.am-plus svg {
width: 18px;
height: 18px;
transition: transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.am-trigger[aria-expanded="true"] .am-plus svg {
transform: rotate(45deg);
}
.am-trigger strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.am-trigger small {
display: block;
font-size: 11px;
color: var(--am-muted);
margin-top: 1px;
}
.am-shortcut {
margin-left: auto;
font-size: 15px;
color: var(--am-muted);
}
.am-panel {
margin-top: 8px;
padding: 6px;
border: 1px solid var(--am-line);
border-radius: 18px;
background: var(--am-bg);
box-shadow:
0 18px 34px -20px #0006,
inset 0 1px #ffffff12;
transform-origin: 50% 0;
animation: am-open 0.3s cubic-bezier(0.18, 0.9, 0.25, 1.15);
}
.am-panel[hidden] {
display: none;
}
.am-option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: 0;
padding: 10px;
border-radius: 12px;
text-align: left;
font: inherit;
color: var(--am-ink);
background: transparent;
cursor: pointer;
transition:
background 0.18s,
transform 0.18s;
}
.am-option:hover,
.am-option:focus-visible {
background: var(--am-hover);
}
.am-option:active {
transform: scale(0.98);
}
.am-icon {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid var(--am-line);
border-radius: 10px;
background: var(--am-hover);
}
.am-icon svg {
width: 17px;
height: 17px;
}
.am-option strong {
display: block;
font-size: 12px;
font-weight: 600;
}
.am-option small {
display: block;
color: var(--am-muted);
font-size: 10px;
margin-top: 1px;
}
.am-option b {
font-weight: 400;
margin-left: auto;
color: var(--am-muted);
}
.am-root button:focus-visible {
outline: 2px solid #839d58;
outline-offset: 2px;
}
.am-status {
margin: 10px 0 0;
color: var(--am-muted);
text-align: center;
font-size: 11px;
min-height: 16px;
}
@keyframes am-open {
from {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.am-panel {
animation: none;
}
.am-root * {
transition: none;
}
}
`;
const actions = [
{
id: "project",
title: "Project",
description: "A home for your next idea",
path: "M3 6h6l2 2h10v11H3z",
},
{
id: "canvas",
title: "Canvas",
description: "Start with a blank page",
path: "M5 3h14v18H5z M8 7h8 M8 11h5",
},
{
id: "collection",
title: "Collection",
description: "Keep the good things together",
path: "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z",
},
];
/** A disclosure with ordinary buttons, so Tab works without a menu's roving focus. */
export function ActionMenu({
defaultOpen = false,
onSelect,
}: {
defaultOpen?: boolean;
onSelect?: (action: string) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
const [selection, setSelection] = useState("");
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const id = useId();
useEffect(() => {
if (!open) return;
const dismiss = (event: PointerEvent) => {
if (event.target instanceof Node && !root.current?.contains(event.target))
setOpen(false);
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [open]);
return (
<>
<style>{css}</style>
<div
className="am-root"
ref={root}
onKeyDown={(event) => {
if (event.key === "Escape" && open) {
event.preventDefault();
setOpen(false);
trigger.current?.focus();
}
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
setOpen(false);
}}
>
<button
className="am-trigger"
type="button"
ref={trigger}
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((value) => !value)}
>
<span className="am-plus" aria-hidden="true">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</span>
<span>
<strong>Create something</strong>
<small>A little space to begin.</small>
</span>
<span className="am-shortcut" aria-hidden="true">
⌄
</span>
</button>
<div className="am-panel" id={id} hidden={!open}>
{actions.map((action) => (
<button
className="am-option"
key={action.id}
data-action={action.id}
type="button"
onClick={() => {
setSelection(`${action.title} selected`);
setOpen(false);
trigger.current?.focus();
onSelect?.(action.id);
}}
>
<span className="am-icon" aria-hidden="true">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
>
<path d={action.path} />
</svg>
</span>
<span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</span>
<b aria-hidden="true">↗</b>
</button>
))}
</div>
<p className="am-status" role="status">
{selection}
</p>
</div>
</>
);
}