Skip to content
Gx
GitHub

Menubar

A horizontal bar of menu actions.

<menubar.Menubar>
  <menubar.MenubarMenu id="demo-menubar-file" label="File">
    <menubar.MenubarItem>
      New tab
      <menubar.MenubarShortcut>⌘T</menubar.MenubarShortcut>
    </menubar.MenubarItem>
    <menubar.MenubarItem>New window</menubar.MenubarItem>
    <menubar.MenubarSeparator />
    <menubar.MenubarLink href={gx.URL("/docs")}>Documentation</menubar.MenubarLink>
  </menubar.MenubarMenu>
  <menubar.MenubarMenu id="demo-menubar-view" label="View">
    <menubar.MenubarCheckboxItem name="bookmarks">Always show bookmarks bar</menubar.MenubarCheckboxItem>
    <menubar.MenubarCheckboxItem name="urls" checked>Always show full URLs</menubar.MenubarCheckboxItem>
    <menubar.MenubarSeparator />
    <menubar.MenubarItem inset>
      Reload
      <menubar.MenubarShortcut>⌘R</menubar.MenubarShortcut>
    </menubar.MenubarItem>
    <menubar.MenubarItem inset disabled>Force reload</menubar.MenubarItem>
  </menubar.MenubarMenu>
  <menubar.MenubarMenu id="demo-menubar-profiles" label="Profiles">
    <menubar.MenubarRadioGroup label="Profile">
      <menubar.MenubarLabel inset>Profile</menubar.MenubarLabel>
      <menubar.MenubarRadioItem name="demo-menubar-profile" value="ada">Ada</menubar.MenubarRadioItem>
      <menubar.MenubarRadioItem name="demo-menubar-profile" value="grace" checked>Grace</menubar.MenubarRadioItem>
    </menubar.MenubarRadioGroup>
    <menubar.MenubarSeparator />
    <menubar.MenubarItem inset variant={menubar.Destructive}>Remove profile</menubar.MenubarItem>
  </menubar.MenubarMenu>
</menubar.Menubar>

Installation

Run the command in the app module.

Terminal
gx add menubar

The command also installs icons.

The command writes these files.

File Path in the app
Menubar.gx ui/menubar/Menubar.gx
MenubarCheckboxItem.gx ui/menubar/MenubarCheckboxItem.gx
MenubarGroup.gx ui/menubar/MenubarGroup.gx
MenubarItem.gx ui/menubar/MenubarItem.gx
MenubarLabel.gx ui/menubar/MenubarLabel.gx
MenubarLink.gx ui/menubar/MenubarLink.gx
MenubarMenu.gx ui/menubar/MenubarMenu.gx
MenubarRadioGroup.gx ui/menubar/MenubarRadioGroup.gx
MenubarRadioItem.gx ui/menubar/MenubarRadioItem.gx
MenubarSeparator.gx ui/menubar/MenubarSeparator.gx
MenubarShortcut.gx ui/menubar/MenubarShortcut.gx
MenubarSub.gx ui/menubar/MenubarSub.gx
MenubarSubContent.gx ui/menubar/MenubarSubContent.gx
MenubarSubTrigger.gx ui/menubar/MenubarSubTrigger.gx
Menubar.fixtures.go ui/menubar/Menubar.fixtures.go
MenubarCheckboxItem.fixtures.go ui/menubar/MenubarCheckboxItem.fixtures.go
MenubarGroup.fixtures.go ui/menubar/MenubarGroup.fixtures.go
MenubarItem.fixtures.go ui/menubar/MenubarItem.fixtures.go
MenubarLabel.fixtures.go ui/menubar/MenubarLabel.fixtures.go
MenubarLink.fixtures.go ui/menubar/MenubarLink.fixtures.go
MenubarMenu.fixtures.go ui/menubar/MenubarMenu.fixtures.go
MenubarRadioGroup.fixtures.go ui/menubar/MenubarRadioGroup.fixtures.go
MenubarRadioItem.fixtures.go ui/menubar/MenubarRadioItem.fixtures.go
MenubarSeparator.fixtures.go ui/menubar/MenubarSeparator.fixtures.go
MenubarShortcut.fixtures.go ui/menubar/MenubarShortcut.fixtures.go
MenubarSub.fixtures.go ui/menubar/MenubarSub.fixtures.go
MenubarSubContent.fixtures.go ui/menubar/MenubarSubContent.fixtures.go
MenubarSubTrigger.fixtures.go ui/menubar/MenubarSubTrigger.fixtures.go
styles.go ui/menubar/styles.go

Install icons first.

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/menubar/Menubar.gx
package menubar

props {
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the menus of the bar.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div role="menubar" data-gx-roving="nowrap" class={gx.Cx("flex h-9 items-center gap-1 rounded-md border border-border bg-background p-1 shadow-xs", p.Class)} {...p.Attrs}>{p.Children}</div>
ui/menubar/MenubarCheckboxItem.gx
package menubar

import "github.com/alternayte/gx/registry/icons"

props {
  // Name is the name attribute of the checkbox input. A form or a signal reads it.
  Name     string = ""
  // Value is the value that the form sends when the item is checked.
  Value    string = "on"
  // Checked sets the item as checked on the first render.
  Checked  bool = false
  // Disabled turns the item off. The arrow keys pass it.
  Disabled bool = false
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the label of the item.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<label class={gx.Cx("relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm select-none hover:bg-accent hover:text-accent-foreground has-[:focus]:bg-accent has-[:focus]:text-accent-foreground has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", p.Class)} {...p.Attrs}>
  <input type="checkbox" role="menuitemcheckbox" tabindex="-1" name={p.Name} value={p.Value} checked={p.Checked} disabled={p.Disabled} class="peer sr-only" {...rovingItem(p.Disabled)} />
  <span class="pointer-events-none invisible absolute left-2 flex size-3.5 items-center justify-center peer-checked:visible"><icons.Check class="size-4" /></span>
  {p.Children}
</label>
ui/menubar/MenubarGroup.gx
package menubar

props {
  // Label is the accessible name of the group. It is not visible.
  Label    string = ""
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the items of the group.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div role="group" aria-label={p.Label} class={p.Class} {...p.Attrs}>{p.Children}</div>
ui/menubar/MenubarItem.gx
package menubar

props {
  // Variant sets the visual style: Default or Destructive.
  Variant  Variant = Default
  // Inset adds left padding, so the item lines up with the checkbox and radio items.
  Inset    bool = false
  // Disabled turns the item off. The arrow keys pass it.
  Disabled bool = false
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the content of the item.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<button type="button" role="menuitem" tabindex="-1" disabled={p.Disabled} data-gx-close class={gx.Cx("relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", variantClass[p.variant()], insetClass[p.Inset], p.Class)} {...rovingItem(p.Disabled)} {...p.Attrs}>{p.Children}</button>
ui/menubar/MenubarLabel.gx
package menubar

props {
  // Inset adds left padding, so the label lines up with the checkbox and radio items.
  Inset    bool = false
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the text of the label.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div role="presentation" class={gx.Cx("px-2 py-1.5 text-sm font-medium", insetClass[p.Inset], p.Class)} {...p.Attrs}>{p.Children}</div>
ui/menubar/MenubarLink.gx
package menubar

props {
  // Href is the URL that the link opens.
  Href     gx.URL
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the content of the link.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<a role="menuitem" tabindex="-1" href={p.Href} data-gx-roving-item data-gx-close class={gx.Cx("relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm no-underline outline-hidden select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", p.Class)} {...p.Attrs}>{p.Children}</a>
ui/menubar/MenubarMenu.gx
package menubar

props {
  // Id is the id of the menu element. The trigger in the bar opens the menu by this id.
  Id       string
  // Label is the text of the trigger in the bar. It is also the accessible name of the menu.
  Label    string
  // Class adds classes to the menu element.
  Class    string = ""
  // Children is the menu items.
  Children gx.Node
  // Attrs adds HTML attributes to the menu element.
  Attrs    gx.Attrs = nil
}

<button type="button" role="menuitem" aria-haspopup="menu" popovertarget={p.Id} data-gx-roving-item style={p.triggerStyle()} class="flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground has-[+:popover-open]:bg-accent has-[+:popover-open]:text-accent-foreground">{p.Label}</button>
<div id={p.Id} popover="auto" role="menu" aria-label={p.Label} data-gx-dismiss data-gx-roving="nowrap" data-gx-place="bottom start 8 -4" style={p.contentStyle()} class={gx.Cx("z-50 min-w-[12rem] origin-top-left data-[side=top]:origin-bottom-left overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-hidden", motionClass, p.Class)} {...p.Attrs}>{p.Children}</div>
ui/menubar/MenubarRadioGroup.gx
package menubar

props {
  // Label is the accessible name of the group. It is not visible.
  Label    string = ""
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the radio items of the group.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div role="group" aria-label={p.Label} class={p.Class} {...p.Attrs}>{p.Children}</div>
ui/menubar/MenubarRadioItem.gx
package menubar

import "github.com/alternayte/gx/registry/icons"

props {
  // Name is the name attribute of the radio input. The radio items of one group share
  // the same name.
  Name     string = ""
  // Value is the value that the form sends when the item is checked.
  Value    string = ""
  // Checked sets the item as checked on the first render.
  Checked  bool = false
  // Disabled turns the item off. The arrow keys pass it.
  Disabled bool = false
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the label of the item.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<label class={gx.Cx("relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm select-none hover:bg-accent hover:text-accent-foreground has-[:focus]:bg-accent has-[:focus]:text-accent-foreground has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", p.Class)} {...p.Attrs}>
  <input type="radio" role="menuitemradio" tabindex="-1" name={p.Name} value={p.Value} checked={p.Checked} disabled={p.Disabled} class="peer sr-only" {...rovingItem(p.Disabled)} />
  <span class="pointer-events-none invisible absolute left-2 flex size-3.5 items-center justify-center peer-checked:visible"><icons.Circle class="size-2 *:fill-current" /></span>
  {p.Children}
</label>
ui/menubar/MenubarSeparator.gx
package menubar

props {
  // Class adds classes to the root element.
  Class string = ""
  // Attrs adds HTML attributes to the root element.
  Attrs gx.Attrs = nil
}

<div role="separator" class={gx.Cx("-mx-1 my-1 h-px bg-border", p.Class)} {...p.Attrs}></div>
ui/menubar/MenubarShortcut.gx
package menubar

props {
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the text of the keyboard shortcut.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<span class={gx.Cx("ml-auto text-xs tracking-widest text-muted-foreground", p.Class)} {...p.Attrs}>{p.Children}</span>
ui/menubar/MenubarSub.gx
package menubar

props {
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the sub-menu trigger, followed by the sub-menu content.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div role="none" data-gx-sub class={p.Class} {...p.Attrs}>{p.Children}</div>
ui/menubar/MenubarSubContent.gx
package menubar

props {
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the list of items of the sub-menu.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div popover="auto" role="menu" data-gx-roving="nowrap" data-gx-place="right start" class={gx.Cx("z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-hidden", subMotionClass, p.Class)} {...p.Attrs}>{p.Children}</div>
ui/menubar/MenubarSubTrigger.gx
package menubar

import "github.com/alternayte/gx/registry/icons"

props {
  // Inset adds left padding, so the trigger lines up with the checkbox and radio items.
  Inset    bool = false
  // Disabled turns the trigger off. The arrow keys pass it.
  Disabled bool = false
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the label of the trigger. A chevron follows it.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<button type="button" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" disabled={p.Disabled} class={gx.Cx("flex w-full cursor-default items-center rounded-sm px-2 py-1.5 text-left text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground disabled:pointer-events-none disabled:opacity-50", insetClass[p.Inset], p.Class)} {...rovingItem(p.Disabled)} {...p.Attrs}>
  {p.Children}
  <icons.ChevronRight class="ml-auto h-4 w-4" />
</button>
ui/menubar/Menubar.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarFixtures = gx.Fixtures[MenubarProps]{
	"Default": {Children: gx.Frag(
		MenubarMenu(MenubarMenuProps{Id: "demo-menubar-file", Label: "File", Children: gx.Frag(
			MenubarItem(MenubarItemProps{Children: gx.Frag(
				gx.Text("New tab"),
				MenubarShortcut(MenubarShortcutProps{Children: gx.Text("⌘T")}),
			)}),
			MenubarItem(MenubarItemProps{Children: gx.Text("New window")}),
			MenubarSeparator(MenubarSeparatorProps{}),
			MenubarLink(MenubarLinkProps{Href: gx.URL("/docs"), Children: gx.Text("Documentation")}),
		)}),
		MenubarMenu(MenubarMenuProps{Id: "demo-menubar-view", Label: "View", Children: gx.Frag(
			MenubarCheckboxItem(MenubarCheckboxItemProps{Name: "bookmarks", Children: gx.Text("Always show bookmarks bar")}),
			MenubarCheckboxItem(MenubarCheckboxItemProps{Name: "urls", Checked: true, Children: gx.Text("Always show full URLs")}),
			MenubarSeparator(MenubarSeparatorProps{}),
			MenubarItem(MenubarItemProps{Inset: true, Children: gx.Frag(
				gx.Text("Reload"),
				MenubarShortcut(MenubarShortcutProps{Children: gx.Text("⌘R")}),
			)}),
			MenubarItem(MenubarItemProps{Inset: true, Disabled: true, Children: gx.Text("Force reload")}),
		)}),
		MenubarMenu(MenubarMenuProps{Id: "demo-menubar-profiles", Label: "Profiles", Children: gx.Frag(
			MenubarRadioGroup(MenubarRadioGroupProps{Label: "Profile", Children: gx.Frag(
				MenubarLabel(MenubarLabelProps{Inset: true, Children: gx.Text("Profile")}),
				MenubarRadioItem(MenubarRadioItemProps{Name: "demo-menubar-profile", Value: "ada", Children: gx.Text("Ada")}),
				MenubarRadioItem(MenubarRadioItemProps{Name: "demo-menubar-profile", Value: "grace", Checked: true, Children: gx.Text("Grace")}),
			)}),
			MenubarSeparator(MenubarSeparatorProps{}),
			MenubarItem(MenubarItemProps{Inset: true, Variant: Destructive, Children: gx.Text("Remove profile")}),
		)}),
	)},
	"Sub": {Children: gx.Frag(
		MenubarMenu(MenubarMenuProps{Id: "demo-menubar-sub-file", Label: "File", Children: gx.Frag(
			MenubarItem(MenubarItemProps{Children: gx.Text("New tab")}),
			MenubarSub(MenubarSubProps{Children: gx.Frag(
				MenubarSubTrigger(MenubarSubTriggerProps{Children: gx.Text("Share")}),
				MenubarSubContent(MenubarSubContentProps{Children: gx.Frag(
					MenubarItem(MenubarItemProps{Children: gx.Text("Email link")}),
					MenubarItem(MenubarItemProps{Children: gx.Text("Messages")}),
					MenubarItem(MenubarItemProps{Children: gx.Text("Notes")}),
				)}),
			)}),
			MenubarSeparator(MenubarSeparatorProps{}),
			MenubarItem(MenubarItemProps{Children: gx.Text("Print")}),
		)}),
		MenubarMenu(MenubarMenuProps{Id: "demo-menubar-sub-edit", Label: "Edit", Children: gx.Frag(
			MenubarItem(MenubarItemProps{Children: gx.Text("Undo")}),
			MenubarItem(MenubarItemProps{Children: gx.Text("Redo")}),
		)}),
	)},
}
ui/menubar/MenubarCheckboxItem.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarCheckboxItemFixtures = gx.Fixtures[MenubarCheckboxItemProps]{
	"Checked":   {Name: "status-bar", Checked: true, Children: gx.Text("Status bar")},
	"Unchecked": {Name: "panel", Children: gx.Text("Panel")},
	"Disabled":  {Name: "activity", Disabled: true, Children: gx.Text("Activity bar")},
}

// MenubarCheckboxItemWrap renders the item inside a menu, as a page uses it.
func MenubarCheckboxItemWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/MenubarGroup.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarGroupFixtures = gx.Fixtures[MenubarGroupProps]{
	"Default": {Label: "Account", Children: gx.Frag(
		MenubarItem(MenubarItemProps{Children: gx.Text("Profile")}),
		MenubarItem(MenubarItemProps{Children: gx.Text("Billing")}),
	)},
}

// MenubarGroupWrap renders the group inside a menu, as a page uses it.
func MenubarGroupWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/MenubarItem.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarItemFixtures = gx.Fixtures[MenubarItemProps]{
	"Item":        {Children: gx.Text("Profile")},
	"Inset":       {Inset: true, Children: gx.Text("Profile")},
	"Destructive": {Variant: Destructive, Children: gx.Text("Delete")},
	"Disabled":    {Disabled: true, Children: gx.Text("Profile")},
}

// MenubarItemWrap renders the item inside a menu, as a page uses it.
func MenubarItemWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/MenubarLabel.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarLabelFixtures = gx.Fixtures[MenubarLabelProps]{
	"Label": {Children: gx.Text("My account")},
	"Inset": {Inset: true, Children: gx.Text("My account")},
}
ui/menubar/MenubarLink.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarLinkFixtures = gx.Fixtures[MenubarLinkProps]{"Link": {Href: gx.URL("/docs"), Children: gx.Text("Documentation")}}

// MenubarLinkWrap renders the item inside a menu, as a page uses it.
func MenubarLinkWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/MenubarMenu.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarMenuFixtures = gx.Fixtures[MenubarMenuProps]{
	"Menu": {Id: "demo-menubar-menu", Label: "Edit", Children: gx.Frag(
		MenubarItem(MenubarItemProps{Children: gx.Text("Undo")}),
		MenubarItem(MenubarItemProps{Children: gx.Text("Redo")}),
	)},
}

// MenubarMenuWrap renders the menu inside a bar, as a page uses it.
func MenubarMenuWrap(n gx.Node) gx.Node {
	return Menubar(MenubarProps{Children: n})
}
ui/menubar/MenubarRadioGroup.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarRadioGroupFixtures = gx.Fixtures[MenubarRadioGroupProps]{
	"Default": {Label: "Position", Children: gx.Frag(
		MenubarRadioItem(MenubarRadioItemProps{Name: "menubar-group-position", Value: "top", Checked: true, Children: gx.Text("Top")}),
		MenubarRadioItem(MenubarRadioItemProps{Name: "menubar-group-position", Value: "bottom", Children: gx.Text("Bottom")}),
	)},
}

// MenubarRadioGroupWrap renders the group inside a menu, as a page uses it.
func MenubarRadioGroupWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/MenubarRadioItem.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarRadioItemFixtures = gx.Fixtures[MenubarRadioItemProps]{
	"Checked":   {Name: "menubar-item-checked", Value: "top", Checked: true, Children: gx.Text("Top")},
	"Unchecked": {Name: "menubar-item-unchecked", Value: "bottom", Children: gx.Text("Bottom")},
}

// MenubarRadioItemWrap renders the item inside a menu, as a page uses it.
func MenubarRadioItemWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/MenubarSeparator.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarSeparatorFixtures = gx.Fixtures[MenubarSeparatorProps]{"Default": {}}
ui/menubar/MenubarShortcut.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarShortcutFixtures = gx.Fixtures[MenubarShortcutProps]{"Default": {Children: gx.Text("⌘K")}}
ui/menubar/MenubarSub.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarSubFixtures = gx.Fixtures[MenubarSubProps]{
	"Default": {Children: gx.Frag(
		MenubarSubTrigger(MenubarSubTriggerProps{Children: gx.Text("More tools")}),
		MenubarSubContent(MenubarSubContentProps{Children: gx.Frag(
			MenubarItem(MenubarItemProps{Children: gx.Text("Save page")}),
			MenubarItem(MenubarItemProps{Children: gx.Text("Create shortcut")}),
		)}),
	)},
}

// MenubarSubWrap renders the sub-menu inside a menu, as a page uses it.
func MenubarSubWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/MenubarSubContent.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarSubContentFixtures = gx.Fixtures[MenubarSubContentProps]{
	"Content": {Class: "w-48", Children: gx.Frag(
		MenubarItem(MenubarItemProps{Children: gx.Text("Save page")}),
		MenubarItem(MenubarItemProps{Children: gx.Text("Create shortcut")}),
		MenubarSeparator(MenubarSeparatorProps{}),
		MenubarItem(MenubarItemProps{Children: gx.Text("Developer tools")}),
	)},
}

// MenubarSubContentWrap renders the content behind its trigger inside a menu,
// as a page uses it.
func MenubarSubContentWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}},
		MenubarSub(MenubarSubProps{Children: gx.Frag(
			MenubarSubTrigger(MenubarSubTriggerProps{Children: gx.Text("More tools")}),
			n,
		)}),
	)
}
ui/menubar/MenubarSubTrigger.fixtures.go
package menubar

import "github.com/alternayte/gx"

var MenubarSubTriggerFixtures = gx.Fixtures[MenubarSubTriggerProps]{
	"Trigger":  {Children: gx.Text("More tools")},
	"Inset":    {Inset: true, Children: gx.Text("More tools")},
	"Disabled": {Disabled: true, Children: gx.Text("More tools")},
}

// MenubarSubTriggerWrap renders the trigger inside a menu, as a page uses it.
func MenubarSubTriggerWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}}, n)
}
ui/menubar/styles.go
package menubar

import "github.com/alternayte/gx"

// triggerStyle names the trigger of one menu as the anchor of its content.
func (p MenubarMenuProps) triggerStyle() gx.Style {
	return gx.Style("anchor-name: --gx-menubar-" + p.Id)
}

// contentStyle anchors the content below its trigger, 8px down and 4px to
// the left as the reference offsets do, for a page whose scripts did not
// run. The overlay module then writes the measured place from the same
// offsets.
func (p MenubarMenuProps) contentStyle() gx.Style {
	return gx.Style("position-anchor: --gx-menubar-" + p.Id + "; inset: auto; margin: 0.5rem 0 0 -0.25rem; top: anchor(bottom); left: anchor(left)")
}

// Variant is the visual style of a menu item.
type Variant string

// The variants of menubar.MenubarItem.
const (
	Default     Variant = "default"
	Destructive Variant = "destructive"
)

var variantClass = gx.Enum[Variant]{
	Default:     "",
	Destructive: "text-destructive hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive dark:hover:bg-destructive/20 dark:focus:bg-destructive/20 *:[svg]:text-destructive!",
}

// variant returns the variant of one item; a zero value is Default.
func (p MenubarItemProps) variant() Variant {
	if p.Variant == "" {
		return Default
	}
	return p.Variant
}

// insetClass lines an item or a label up with the checkbox and radio items.
var insetClass = map[bool]string{
	true:  "pl-8",
	false: "",
}

// rovingItem marks an item for the roving tabindex of its menu. A disabled
// item is not marked, so the arrow keys pass it.
func rovingItem(disabled bool) gx.Attrs {
	return gx.Attrs{gx.Bool("data-gx-roving-item", !disabled)}
}

// motionClass fades and zooms the menu from 95% and slides it 2 units from
// the trigger, from below when the menu took the top side. Safari 26.0 never ends a display transition on an element
// that CSS anchor positioning places, which leaves a closed menu rendered.
// The @supports test matches every engine but WebKit, so Safari closes the
// menu at once and still animates the enter.
const motionClass = "opacity-0 scale-95 transition-[opacity,scale,translate,overlay,display] not-supports-[font:-apple-system-body]:transition-discrete duration-150 open:opacity-100 open:scale-100 starting:open:opacity-0 starting:open:scale-95 starting:open:-translate-y-2 data-[side=top]:starting:open:translate-y-2 motion-reduce:transition-none"

// subMotionClass fades and zooms the content of a sub-menu from 95% and
// slides it 2 units from its trigger. The overlay module writes data-side:
// content that flips to the left slides from the right.
const subMotionClass = "origin-top-left opacity-0 scale-95 transition-[opacity,scale,translate,overlay,display] transition-discrete duration-150 open:opacity-100 open:scale-100 starting:open:opacity-0 starting:open:scale-95 starting:open:-translate-x-2 data-[side=left]:origin-top-right data-[side=left]:starting:open:translate-x-2 motion-reduce:transition-none"

The theme must define these tokens: --border, --background, --popover, --popover-foreground, --accent, --accent-foreground, --muted-foreground, --destructive.

Usage

A horizontal bar of menus.

<menubar.Menubar>
  <menubar.MenubarMenu id="file-menu" label="File">
    <menubar.MenubarItem>
      New tab
      <menubar.MenubarShortcut>⌘T</menubar.MenubarShortcut>
    </menubar.MenubarItem>
    <menubar.MenubarSub>
      <menubar.MenubarSubTrigger>Share</menubar.MenubarSubTrigger>
      <menubar.MenubarSubContent>
        <menubar.MenubarItem>Email link</menubar.MenubarItem>
        <menubar.MenubarItem>Messages</menubar.MenubarItem>
      </menubar.MenubarSubContent>
    </menubar.MenubarSub>
    <menubar.MenubarSeparator />
    <menubar.MenubarLink href={gx.URL("/docs")}>Documentation</menubar.MenubarLink>
  </menubar.MenubarMenu>
  <menubar.MenubarMenu id="view-menu" label="View">
    <menubar.MenubarCheckboxItem name="urls" checked={true}>Always show full URLs</menubar.MenubarCheckboxItem>
    <menubar.MenubarSeparator />
    <menubar.MenubarItem inset={true}>Reload</menubar.MenubarItem>
  </menubar.MenubarMenu>
</menubar.Menubar>

MenubarMenu renders the trigger in the bar and the menu that opens from it. Label is the text of the trigger and Id is the id of the menu. The menu uses the native Popover API.

A menu opens below its trigger. It flips above the trigger when the space below is too small, and it shifts along the trigger to stay 8px inside the viewport. data-side on the menu names the side it took.

A checkbox item and a radio item are native inputs. They carry Name and Value, so a form or a signal reads them. They keep the menu open. An item has Inset, Disabled and the Destructive variant.

A menu fades and zooms in and out. Safari shows the enter transition only. A user who asks for reduced motion gets no transition.

A click opens a menu. With one menu open, the pointer opens the menu of each trigger it moves over.

MenubarSub holds one MenubarSubTrigger and one MenubarSubContent, in that order. The sub-menu opens to the right of its trigger, or to the left when the right side is too small. It opens on a click and after the pointer rests on the trigger. It stays open while the pointer moves toward it. An item of a sub-menu closes the whole menu. The trigger has Inset and Disabled.

Examples

<menubar.Menubar>
  <menubar.MenubarMenu id="demo-menubar-file" label="File">
    <menubar.MenubarItem>
      New tab
      <menubar.MenubarShortcut>⌘T</menubar.MenubarShortcut>
    </menubar.MenubarItem>
    <menubar.MenubarItem>New window</menubar.MenubarItem>
    <menubar.MenubarSeparator />
    <menubar.MenubarLink href={gx.URL("/docs")}>Documentation</menubar.MenubarLink>
  </menubar.MenubarMenu>
  <menubar.MenubarMenu id="demo-menubar-view" label="View">
    <menubar.MenubarCheckboxItem name="bookmarks">Always show bookmarks bar</menubar.MenubarCheckboxItem>
    <menubar.MenubarCheckboxItem name="urls" checked>Always show full URLs</menubar.MenubarCheckboxItem>
    <menubar.MenubarSeparator />
    <menubar.MenubarItem inset>
      Reload
      <menubar.MenubarShortcut>⌘R</menubar.MenubarShortcut>
    </menubar.MenubarItem>
    <menubar.MenubarItem inset disabled>Force reload</menubar.MenubarItem>
  </menubar.MenubarMenu>
  <menubar.MenubarMenu id="demo-menubar-profiles" label="Profiles">
    <menubar.MenubarRadioGroup label="Profile">
      <menubar.MenubarLabel inset>Profile</menubar.MenubarLabel>
      <menubar.MenubarRadioItem name="demo-menubar-profile" value="ada">Ada</menubar.MenubarRadioItem>
      <menubar.MenubarRadioItem name="demo-menubar-profile" value="grace" checked>Grace</menubar.MenubarRadioItem>
    </menubar.MenubarRadioGroup>
    <menubar.MenubarSeparator />
    <menubar.MenubarItem inset variant={menubar.Destructive}>Remove profile</menubar.MenubarItem>
  </menubar.MenubarMenu>
</menubar.Menubar>
<menubar.Menubar>
  <menubar.MenubarMenu id="demo-menubar-sub-file" label="File">
    <menubar.MenubarItem>New tab</menubar.MenubarItem>
    <menubar.MenubarSub>
      <menubar.MenubarSubTrigger>Share</menubar.MenubarSubTrigger>
      <menubar.MenubarSubContent>
        <menubar.MenubarItem>Email link</menubar.MenubarItem>
        <menubar.MenubarItem>Messages</menubar.MenubarItem>
        <menubar.MenubarItem>Notes</menubar.MenubarItem>
      </menubar.MenubarSubContent>
    </menubar.MenubarSub>
    <menubar.MenubarSeparator />
    <menubar.MenubarItem>Print</menubar.MenubarItem>
  </menubar.MenubarMenu>
  <menubar.MenubarMenu id="demo-menubar-sub-edit" label="Edit">
    <menubar.MenubarItem>Undo</menubar.MenubarItem>
    <menubar.MenubarItem>Redo</menubar.MenubarItem>
  </menubar.MenubarMenu>
</menubar.Menubar>
<menubar.MenubarCheckboxItem name="status-bar" checked>Status bar</menubar.MenubarCheckboxItem>
<menubar.MenubarCheckboxItem name="panel">Panel</menubar.MenubarCheckboxItem>
<menubar.MenubarCheckboxItem name="activity" disabled>Activity bar</menubar.MenubarCheckboxItem>
<menubar.MenubarGroup label="Account">
  <menubar.MenubarItem>Profile</menubar.MenubarItem>
  <menubar.MenubarItem>Billing</menubar.MenubarItem>
</menubar.MenubarGroup>
<menubar.MenubarItem>Profile</menubar.MenubarItem>
<menubar.MenubarItem inset>Profile</menubar.MenubarItem>
<menubar.MenubarItem variant={menubar.Destructive}>Delete</menubar.MenubarItem>
<menubar.MenubarItem disabled>Profile</menubar.MenubarItem>
<menubar.MenubarLabel>My account</menubar.MenubarLabel>
<menubar.MenubarLabel inset>My account</menubar.MenubarLabel>
<menubar.MenubarLink href={gx.URL("/docs")}>Documentation</menubar.MenubarLink>
<menubar.MenubarMenu id="demo-menubar-menu" label="Edit">
  <menubar.MenubarItem>Undo</menubar.MenubarItem>
  <menubar.MenubarItem>Redo</menubar.MenubarItem>
</menubar.MenubarMenu>
<menubar.MenubarRadioGroup label="Position">
  <menubar.MenubarRadioItem name="menubar-group-position" value="top" checked>Top</menubar.MenubarRadioItem>
  <menubar.MenubarRadioItem name="menubar-group-position" value="bottom">Bottom</menubar.MenubarRadioItem>
</menubar.MenubarRadioGroup>
<menubar.MenubarRadioItem name="menubar-item-checked" value="top" checked>Top</menubar.MenubarRadioItem>
<menubar.MenubarRadioItem name="menubar-item-unchecked" value="bottom">Bottom</menubar.MenubarRadioItem>
<menubar.MenubarSeparator />
<menubar.MenubarShortcut>⌘K</menubar.MenubarShortcut>
<menubar.MenubarSub>
  <menubar.MenubarSubTrigger>More tools</menubar.MenubarSubTrigger>
  <menubar.MenubarSubContent>
    <menubar.MenubarItem>Save page</menubar.MenubarItem>
    <menubar.MenubarItem>Create shortcut</menubar.MenubarItem>
  </menubar.MenubarSubContent>
</menubar.MenubarSub>
<menubar.MenubarSubContent class="w-48">
  <menubar.MenubarItem>Save page</menubar.MenubarItem>
  <menubar.MenubarItem>Create shortcut</menubar.MenubarItem>
  <menubar.MenubarSeparator />
  <menubar.MenubarItem>Developer tools</menubar.MenubarItem>
</menubar.MenubarSubContent>
<menubar.MenubarSubTrigger>More tools</menubar.MenubarSubTrigger>
<menubar.MenubarSubTrigger inset>More tools</menubar.MenubarSubTrigger>
<menubar.MenubarSubTrigger disabled>More tools</menubar.MenubarSubTrigger>

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

Prop Type Default Description
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the menus of the bar.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Name string "" Name is the name attribute of the checkbox input. A form or a signal reads it.
Value string "on" Value is the value that the form sends when the item is checked.
Checked bool false Checked sets the item as checked on the first render.
Disabled bool false Disabled turns the item off. The arrow keys pass it.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the label of the item.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Label string "" Label is the accessible name of the group. It is not visible.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the items of the group.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Variant Variant Default Variant sets the visual style: Default or Destructive.
Inset bool false Inset adds left padding, so the item lines up with the checkbox and radio items.
Disabled bool false Disabled turns the item off. The arrow keys pass it.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the content of the item.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Inset bool false Inset adds left padding, so the label lines up with the checkbox and radio items.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the text of the label.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Href gx.URL Required Href is the URL that the link opens.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the content of the link.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Id string Required Id is the id of the menu element. The trigger in the bar opens the menu by this id.
Label string Required Label is the text of the trigger in the bar. It is also the accessible name of the menu.
Class string "" Class adds classes to the menu element.
Children gx.Node Required Children is the menu items.
Attrs gx.Attrs nil Attrs adds HTML attributes to the menu element.
Prop Type Default Description
Label string "" Label is the accessible name of the group. It is not visible.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the radio items of the group.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Name string "" Name is the name attribute of the radio input. The radio items of one group share the same name.
Value string "" Value is the value that the form sends when the item is checked.
Checked bool false Checked sets the item as checked on the first render.
Disabled bool false Disabled turns the item off. The arrow keys pass it.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the label of the item.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Class string "" Class adds classes to the root element.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the text of the keyboard shortcut.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the sub-menu trigger, followed by the sub-menu content.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the list of items of the sub-menu.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.
Prop Type Default Description
Inset bool false Inset adds left padding, so the trigger lines up with the checkbox and radio items.
Disabled bool false Disabled turns the trigger off. The arrow keys pass it.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the label of the trigger. A chevron follows it.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

Do and do not

Do

  • Give each menu a unique Id.
  • Keep the bar to one row.
  • Give the radio items of one group the same Name.

Don't

  • Do not put a link or a button directly in the bar. Put it in a menu.
  • Do not use a menubar for a site header. Use the navigation menu.
  • Do not put an element between the trigger and the content of a sub-menu.

Keyboard

Key Action
Tab Enters the bar at one trigger and leaves it again.
Left, Right On a trigger, moves to the previous or next trigger. In a menu, opens the previous or next menu.
Right, Enter, Space On a sub-menu trigger, opens the sub-menu and moves to its first item.
Left, Escape In a sub-menu, closes it and returns focus to its trigger.
Home, End Moves to the first or last trigger, or to the first or last item of a menu.
Enter, Space Opens the menu of the focused trigger.
Down Opens the menu and moves to the first item.
Up, Down Moves through the items. A disabled item is passed.
A letter Moves to the next item that starts with the typed text.
Enter Runs the focused item and closes the menu. Toggles a checkbox or radio item.
Escape Closes the menu and returns focus to its trigger.