Tone menubars
Five menubars that keep one skeleton and vary only the semantic tone: info, success, warning, danger and a neutral muted. Each reads its own token family for the soft bar surface, the solid open menu, the panel border and the item hover. Self-contained (no radix), sized, token-driven, and closes on outside click or Escape.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/menubar-toneDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/menubar-tone.tsx"use client"
import * as React from "react"
import { AlertTriangle, CheckCircle2, Info, Minus, OctagonAlert } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Tone menubar family: 5 decorative, SELF-SUFFICIENT menubars. NO radix or portal.
The skeleton is the same in every variant; the whole difference is the SEMANTIC
TONE family: info, success, warning, danger and the neutral muted. Each variant
uses its own token family (<tone>, <tone>-foreground, <tone>-soft,
<tone>-soft-foreground): a soft tone surface for the bar, a filled tone for the
open menu, a tone border for the panel and tone-soft for item hover. Classic
menubar behavior: while one menu is open, hovering a sibling trigger switches to
that menu. It closes on an outside click, on Escape (focus returns to the
trigger) and on an item selection. Left/Right arrows move between the top-level
menus and Up/Down moves inside the open one. No transform under reduced motion.
Color comes ONLY from tokens, via alpha color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
export type MenubarTone = "info" | "success" | "warning" | "danger" | "muted"
type MenuItem = { label: React.ReactNode; onSelect?: () => void }
type Menu = { label: React.ReactNode; items: MenuItem[] }
interface MenubarProps {
className?: string
size?: StyledSize
menus?: Menu[]
}
const defaultMenus: Menu[] = [
{
label: "File",
items: [
{ label: "New File" },
{ label: "Open..." },
{ label: "Save" },
{ label: "Save As..." },
],
},
{
label: "Edit",
items: [
{ label: "Undo" },
{ label: "Redo" },
{ label: "Cut" },
{ label: "Copy" },
{ label: "Paste" },
],
},
{
label: "View",
items: [{ label: "Zoom In" }, { label: "Zoom Out" }, { label: "Full Screen" }],
},
]
const barSize: Record<StyledSize, string> = {
sm: "h-8 gap-0.5 px-1 text-xs",
md: "h-9 gap-1 px-1 text-sm",
lg: "h-10 gap-1 px-1.5 text-sm",
xl: "h-11 gap-1.5 px-2 text-base",
}
const triggerSize: Record<StyledSize, string> = {
sm: "h-6 px-2",
md: "h-7 px-2.5",
lg: "h-8 px-3",
xl: "h-9 px-3.5",
}
const panelSize: Record<StyledSize, string> = {
sm: "min-w-40 text-xs",
md: "min-w-44 text-sm",
lg: "min-w-48 text-sm",
xl: "min-w-52 text-base",
}
const itemSize: Record<StyledSize, string> = {
sm: "px-2 py-1.5",
md: "px-2.5 py-1.5",
lg: "px-3 py-2",
xl: "px-3.5 py-2",
}
const triggerBase =
"inline-flex select-none items-center gap-1.5 whitespace-nowrap rounded-md font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:size-4 [&>svg]:shrink-0 [&>i]:text-base [&>i]:leading-none"
const itemBase =
"flex w-full cursor-default select-none items-center gap-2 rounded-md text-left outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:size-4 [&>svg]:shrink-0 [&>i]:text-base [&>i]:leading-none"
const panelBase =
"absolute left-0 top-full z-50 mt-1.5 origin-top rounded-lg border p-1 shadow-lg outline-none"
const barBase = "inline-flex items-center rounded-lg border shadow-xs"
type ToneStyle = {
bar: string
triggerActive: string
triggerIdle: string
panel: string
item: string
lead: string
Icon: React.ComponentType<{ className?: string }>
}
/* Every tone is fed from its own token family. The classes are written out
statically so the Tailwind scanner can see them. */
const tones: Record<MenubarTone, ToneStyle> = {
info: {
bar: "border-info/30 bg-info-soft",
triggerActive: "bg-info text-info-foreground shadow-sm",
triggerIdle: "text-info-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-info)_16%,transparent)]",
panel: "border-info/30 bg-popover text-popover-foreground",
item: "text-popover-foreground hover:bg-info-soft hover:text-info-soft-foreground",
lead: "text-info",
Icon: Info,
},
success: {
bar: "border-success/30 bg-success-soft",
triggerActive: "bg-success text-success-foreground shadow-sm",
triggerIdle:
"text-success-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-success)_16%,transparent)]",
panel: "border-success/30 bg-popover text-popover-foreground",
item: "text-popover-foreground hover:bg-success-soft hover:text-success-soft-foreground",
lead: "text-success",
Icon: CheckCircle2,
},
warning: {
bar: "border-warning/40 bg-warning-soft",
triggerActive: "bg-warning text-warning-foreground shadow-sm",
triggerIdle:
"text-warning-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-warning)_20%,transparent)]",
panel: "border-warning/40 bg-popover text-popover-foreground",
item: "text-popover-foreground hover:bg-warning-soft hover:text-warning-soft-foreground",
lead: "text-warning-soft-foreground",
Icon: AlertTriangle,
},
danger: {
bar: "border-danger/30 bg-danger-soft",
triggerActive: "bg-danger text-danger-foreground shadow-sm",
triggerIdle:
"text-danger-soft-foreground hover:bg-[color-mix(in_oklab,var(--color-danger)_16%,transparent)]",
panel: "border-danger/30 bg-popover text-popover-foreground",
item: "text-popover-foreground hover:bg-danger-soft hover:text-danger-soft-foreground",
lead: "text-danger",
Icon: OctagonAlert,
},
muted: {
bar: "border-border bg-muted",
triggerActive: "bg-foreground text-background shadow-sm",
triggerIdle:
"text-muted-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)] hover:text-foreground",
panel: "border-border bg-popover text-popover-foreground",
item: "text-popover-foreground hover:bg-muted hover:text-foreground",
lead: "text-muted-foreground",
Icon: Minus,
},
}
/* The shared menubar core. The panel is always absolutely positioned inside the
relative wrapper of its own trigger, so many examples on a single page never
collide. */
function MenubarCore({ props, tone }: { props: MenubarProps; tone: MenubarTone }) {
const { className, size = "md", menus = defaultMenus } = props
const t = tones[tone]
const reduce = useReducedMotion()
const uid = React.useId()
const [openIndex, setOpenIndex] = React.useState<number | null>(null)
const [autoFocusItem, setAutoFocusItem] = React.useState(false)
const rootRef = React.useRef<HTMLDivElement>(null)
const triggerRefs = React.useRef<Array<HTMLButtonElement | null>>([])
const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([])
React.useEffect(() => {
if (openIndex === null) return
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return
triggerRefs.current[openIndex]?.focus()
setOpenIndex(null)
}
const onPointer = (e: PointerEvent) => {
const node = rootRef.current
if (node && e.target instanceof Node && !node.contains(e.target)) {
setOpenIndex(null)
}
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [openIndex])
React.useEffect(() => {
if (openIndex === null || !autoFocusItem) return
const id = window.requestAnimationFrame(() => itemRefs.current[0]?.focus())
setAutoFocusItem(false)
return () => window.cancelAnimationFrame(id)
}, [openIndex, autoFocusItem])
const moveTrigger = (from: number, dir: 1 | -1) => {
const next = (from + dir + menus.length) % menus.length
triggerRefs.current[next]?.focus()
if (openIndex !== null) setOpenIndex(next)
}
const onTriggerKeyDown = (e: React.KeyboardEvent, i: number) => {
if (e.key === "ArrowRight") {
e.preventDefault()
moveTrigger(i, 1)
} else if (e.key === "ArrowLeft") {
e.preventDefault()
moveTrigger(i, -1)
} else if (e.key === "ArrowDown") {
e.preventDefault()
setOpenIndex(i)
setAutoFocusItem(true)
}
}
const onItemKeyDown = (e: React.KeyboardEvent, j: number, count: number, i: number) => {
if (e.key === "ArrowDown") {
e.preventDefault()
itemRefs.current[(j + 1) % count]?.focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
itemRefs.current[(j - 1 + count) % count]?.focus()
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault()
moveTrigger(i, e.key === "ArrowRight" ? 1 : -1)
setAutoFocusItem(true)
}
}
const enter = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: {
initial: { opacity: 0, scale: 0.97, y: -4 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.97, y: -4 },
}
const Icon = t.Icon
return (
<div
ref={rootRef}
data-slot="styled-menubar"
data-tone={tone}
role="menubar"
className={cn(barBase, barSize[size], t.bar, className)}
>
{menus.map((menu, i) => {
const isOpen = openIndex === i
const triggerId = `${uid}-trigger-${i}`
const menuId = `${uid}-menu-${i}`
return (
<div key={i} className="relative">
<button
type="button"
id={triggerId}
ref={(node) => {
triggerRefs.current[i] = node
}}
role="menuitem"
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={isOpen ? menuId : undefined}
onClick={() => setOpenIndex(isOpen ? null : i)}
onPointerEnter={() => {
if (openIndex !== null) setOpenIndex(i)
}}
onKeyDown={(e) => onTriggerKeyDown(e, i)}
className={cn(
triggerBase,
triggerSize[size],
isOpen ? t.triggerActive : t.triggerIdle
)}
>
{menu.label}
</button>
<AnimatePresence>
{isOpen ? (
<motion.div
id={menuId}
role="menu"
aria-labelledby={triggerId}
data-tone={tone}
className={cn(panelBase, panelSize[size], t.panel)}
initial={enter.initial}
animate={enter.animate}
exit={enter.exit}
transition={
reduce
? { duration: 0.12 }
: { type: "spring" as const, stiffness: 360, damping: 26 }
}
>
{menu.items.map((item, j) => (
<button
key={j}
type="button"
role="menuitem"
ref={(node) => {
if (openIndex === i) itemRefs.current[j] = node
}}
onClick={() => {
item.onSelect?.()
triggerRefs.current[i]?.focus()
setOpenIndex(null)
}}
onKeyDown={(e) => onItemKeyDown(e, j, menu.items.length, i)}
className={cn(itemBase, itemSize[size], t.item)}
>
<Icon className={cn("size-3.5 shrink-0", t.lead)} aria-hidden />
{item.label}
</button>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
})}
</div>
)
}
/* Info: bilgi tonu, notr-mavi token ailesi. */
export function InfoMenubar({ className, size = "md", menus }: MenubarProps) {
return <MenubarCore props={{ className, size, menus }} tone="info" />
}
/* Success: onay tonu, yesil token ailesi. */
export function SuccessMenubar({ className, size = "md", menus }: MenubarProps) {
return <MenubarCore props={{ className, size, menus }} tone="success" />
}
/* Warning: dikkat tonu, sari token ailesi. */
export function WarningMenubar({ className, size = "md", menus }: MenubarProps) {
return <MenubarCore props={{ className, size, menus }} tone="warning" />
}
/* Danger: yikici tonu, kirmizi token ailesi. */
export function DangerMenubar({ className, size = "md", menus }: MenubarProps) {
return <MenubarCore props={{ className, size, menus }} tone="danger" />
}
/* Muted: notr ton, muted token ailesi. */
export function MutedMenubar({ className, size = "md", menus }: MenubarProps) {
return <MenubarCore props={{ className, size, menus }} tone="muted" />
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Info
The info token family across bar, menu and items.
import { InfoMenubar } from "@/components/ui/menubar-tone"
<InfoMenubar />Success
The success token family for confirming flows.
import { SuccessMenubar } from "@/components/ui/menubar-tone"
<SuccessMenubar />Warning
The warning token family for cautious actions.
import { WarningMenubar } from "@/components/ui/menubar-tone"
<WarningMenubar />Danger
The danger token family for destructive menus.
import { DangerMenubar } from "@/components/ui/menubar-tone"
<DangerMenubar />Muted
A neutral menubar on the muted token family.
import { MutedMenubar } from "@/components/ui/menubar-tone"
<MutedMenubar />ai2 Tone menubars: 5 styled variations on the token system
The ai2 Tone menubars are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around application menu bars driven by the semantic tone tokens. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion springs the open dropdown in and out. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transforms are skipped and the menu fades in place.
What is in the ai2 Tone menubars?
5 exports in one file: Info, Success, Warning, Danger and Muted. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion springs the open dropdown in and out.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are skipped and the menu fades in place.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Tone menubars sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.