Skip to content
Plinth

Navigation

Action Menu

A softly unfolding creation menu with tactile icon tiles, clear choices, and keyboard-friendly dismissal.

#menu#disclosure#motion#interaction

Install

npx shadcn@latest add https://plinthui.com/r/action-menu.json
Open in v0
"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>
    </>
  );
}

More in Navigation

Similar tags