Styled menubar
Five menubars: bar, pill, glass, minimal and icon. Each is self-contained (no radix), sized, token-driven, and switches menus on hover once one is open.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/menubar-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/menubar-styled.tsx"use client"
import * as React from "react"
import { FileText, Pencil, Eye } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Menubar family: 5 decorative, SELF-SUFFICIENT menubars. NO radix or portal.
A horizontal bar of triggers (like File/Edit/View) + a dropdown absolutely
positioned BELOW the trigger when clicked. Classic menubar behavior: while one
menu is open, hovering a sibling trigger switches to that menu. Only one menu is
open at a time (the open index is held in state). It closes on an outside click,
on Escape or on an item selection. The open dropdown uses AnimatePresence;
instant under reduced-motion. Color comes ONLY from tokens, via alpha
color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
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 defaultIcons = [FileText, Pencil, Eye]
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 dropdownSize: 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 softHover =
"hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)]"
const itemBase =
"flex w-full cursor-default select-none items-center gap-2 rounded-md text-left text-popover-foreground 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"
/* The shared menubar core. triggerClass(active) and dropdownClass decide the look;
when showIcons is true a token icon is placed before every top-level trigger. */
function MenubarCore({
props,
barClassName,
triggerClassName,
dropdownClassName,
showIcons = false,
}: {
props: MenubarProps
barClassName?: string
triggerClassName?: (active: boolean) => string
dropdownClassName?: string
showIcons?: boolean
}) {
const { className, size = "md", menus = defaultMenus } = props
const reduce = useReducedMotion()
const [openIndex, setOpenIndex] = React.useState<number | null>(null)
const rootRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (openIndex === null) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") 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])
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 },
}
return (
<div
ref={rootRef}
data-slot="styled-menubar"
role="menubar"
className={cn(
"inline-flex items-center",
barSize[size],
barClassName,
className
)}
>
{menus.map((menu, i) => {
const active = openIndex === i
const Icon = showIcons ? defaultIcons[i % defaultIcons.length] : null
return (
<div key={i} className="relative">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={active}
onClick={() => setOpenIndex(active ? null : i)}
onPointerEnter={() => {
if (openIndex !== null) setOpenIndex(i)
}}
className={cn(
"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",
triggerSize[size],
triggerClassName?.(active) ??
(active
? "bg-accent text-accent-foreground"
: cn("text-foreground", softHover))
)}
>
{Icon ? <Icon aria-hidden /> : null}
{menu.label}
</button>
<AnimatePresence>
{active ? (
<motion.div
role="menu"
className={cn(
"absolute left-0 top-full z-50 mt-1.5 origin-top rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none",
dropdownSize[size],
dropdownClassName
)}
initial={enter.initial}
animate={enter.animate}
exit={enter.exit}
transition={
reduce
? { duration: 0.12 }
: { type: "spring", stiffness: 360, damping: 26 }
}
>
{menu.items.map((item, j) => (
<button
key={j}
type="button"
role="menuitem"
onClick={() => {
item.onSelect?.()
setOpenIndex(null)
}}
className={cn(itemBase, itemSize[size], softHover)}
>
{item.label}
</button>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
)
})}
</div>
)
}
/* Bar: klasik uygulama menubar, kenarlikli cubuk. */
export function BarMenubar({ className, size = "md", menus }: MenubarProps) {
return (
<MenubarCore
props={{ className, size, menus }}
barClassName="rounded-lg border border-border bg-card shadow-xs"
/>
)
}
/* Pill: aktif menu triggeri token pill highlight alir. */
export function PillMenubar({ className, size = "md", menus }: MenubarProps) {
return (
<MenubarCore
props={{ className, size, menus }}
barClassName="rounded-full border border-border bg-card"
triggerClassName={(active) =>
active
? "rounded-full bg-primary text-primary-foreground shadow-sm"
: cn(
"rounded-full text-muted-foreground",
"hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] hover:text-foreground"
)
}
dropdownClassName="rounded-xl"
/>
)
}
/* Glass: buzlu cam cubuk + dropdown. */
export function GlassMenubar({ className, size = "md", menus }: MenubarProps) {
return (
<MenubarCore
props={{ className, size, menus }}
barClassName="rounded-xl border border-border bg-[color-mix(in_oklab,var(--color-card)_70%,transparent)] shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-foreground)_12%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-card)_50%,transparent)] supports-[backdrop-filter]:backdrop-blur-xl"
dropdownClassName="border-border bg-[color-mix(in_oklab,var(--color-popover)_75%,transparent)] shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-foreground)_12%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-popover)_55%,transparent)] supports-[backdrop-filter]:backdrop-blur-xl"
/>
)
}
/* Minimal: borderless, text-only triggers; a line under the active one. */
export function MinimalMenubar({ className, size = "md", menus }: MenubarProps) {
return (
<MenubarCore
props={{ className, size, menus }}
barClassName="bg-transparent"
triggerClassName={(active) =>
cn(
"rounded-none border-b-2 transition-colors",
active
? "border-primary text-foreground"
: cn(
"border-transparent text-muted-foreground",
"hover:border-[color-mix(in_oklab,var(--color-foreground)_20%,transparent)] hover:text-foreground"
)
)
}
/>
)
}
/* Icon: a token icon before every top-level trigger. */
export function IconMenubar({ className, size = "md", menus }: MenubarProps) {
return (
<MenubarCore
props={{ className, size, menus }}
barClassName="rounded-lg border border-border bg-card shadow-xs"
showIcons
/>
)
}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.
Bar
A bordered classic app menubar.
import { BarMenubar } from "@/components/ui/menubar-styled"
<BarMenubar />Pill
The active menu gets a token pill.
import { PillMenubar } from "@/components/ui/menubar-styled"
<PillMenubar />Glass
A frosted glass bar and dropdowns.
import { GlassMenubar } from "@/components/ui/menubar-styled"
<GlassMenubar />Minimal
Borderless text triggers with an active underline.
import { MinimalMenubar } from "@/components/ui/menubar-styled"
<MinimalMenubar />Icon
Each top-level trigger has a leading token icon.
import { IconMenubar } from "@/components/ui/menubar-styled"
<IconMenubar />ai2 Styled menubar: 5 styled variations on the token system
The ai2 Styled menubar are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around application menu bars. 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 fades 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 dropdown appears instantly with no animation.
What is in the ai2 Styled menubar?
5 exports in one file: Bar, Pill, Glass, Minimal and Icon. 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 fades the open dropdown in and out.
- Reduced-motion aware: Under prefers-reduced-motion, the dropdown appears instantly with no animation.
- 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 Styled menubar 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.