Skip to content
Gx
GitHub

Context Menu

A menu that opens on a right click.

<contextmenu.ContextMenu id="demo-context" class="w-52">
  <contextmenu.ContextMenuItem>
    Copy
    <contextmenu.ContextMenuShortcut>⌘C</contextmenu.ContextMenuShortcut>
  </contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuItem>Cut</contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuCheckboxItem name="bookmarks" checked>Show bookmarks</contextmenu.ContextMenuCheckboxItem>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuRadioGroup label="People">
    <contextmenu.ContextMenuLabel inset>People</contextmenu.ContextMenuLabel>
    <contextmenu.ContextMenuRadioItem name="demo-context-person" value="ada" checked>
      Ada
    </contextmenu.ContextMenuRadioItem>
    <contextmenu.ContextMenuRadioItem name="demo-context-person" value="grace">Grace</contextmenu.ContextMenuRadioItem>
  </contextmenu.ContextMenuRadioGroup>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuLink href={gx.URL("/docs")}>Docs</contextmenu.ContextMenuLink>
  <contextmenu.ContextMenuItem variant={contextmenu.Destructive}>Delete</contextmenu.ContextMenuItem>
</contextmenu.ContextMenu>
<contextmenu.ContextMenuTrigger id="demo-context" class="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-border text-sm">
  Right-click here
</contextmenu.ContextMenuTrigger>

Installation

Run the command in the app module.

Terminal
gx add context-menu

The command also installs icons.

The command writes these files.

File Path in the app
ContextMenu.gx ui/context-menu/ContextMenu.gx
ContextMenuCheckboxItem.gx ui/context-menu/ContextMenuCheckboxItem.gx
ContextMenuGroup.gx ui/context-menu/ContextMenuGroup.gx
ContextMenuItem.gx ui/context-menu/ContextMenuItem.gx
ContextMenuLabel.gx ui/context-menu/ContextMenuLabel.gx
ContextMenuLink.gx ui/context-menu/ContextMenuLink.gx
ContextMenuRadioGroup.gx ui/context-menu/ContextMenuRadioGroup.gx
ContextMenuRadioItem.gx ui/context-menu/ContextMenuRadioItem.gx
ContextMenuSeparator.gx ui/context-menu/ContextMenuSeparator.gx
ContextMenuShortcut.gx ui/context-menu/ContextMenuShortcut.gx
ContextMenuSub.gx ui/context-menu/ContextMenuSub.gx
ContextMenuSubContent.gx ui/context-menu/ContextMenuSubContent.gx
ContextMenuSubTrigger.gx ui/context-menu/ContextMenuSubTrigger.gx
ContextMenuTrigger.gx ui/context-menu/ContextMenuTrigger.gx
ContextMenu.fixtures.go ui/context-menu/ContextMenu.fixtures.go
ContextMenuCheckboxItem.fixtures.go ui/context-menu/ContextMenuCheckboxItem.fixtures.go
ContextMenuGroup.fixtures.go ui/context-menu/ContextMenuGroup.fixtures.go
ContextMenuItem.fixtures.go ui/context-menu/ContextMenuItem.fixtures.go
ContextMenuLabel.fixtures.go ui/context-menu/ContextMenuLabel.fixtures.go
ContextMenuLink.fixtures.go ui/context-menu/ContextMenuLink.fixtures.go
ContextMenuRadioGroup.fixtures.go ui/context-menu/ContextMenuRadioGroup.fixtures.go
ContextMenuRadioItem.fixtures.go ui/context-menu/ContextMenuRadioItem.fixtures.go
ContextMenuSeparator.fixtures.go ui/context-menu/ContextMenuSeparator.fixtures.go
ContextMenuShortcut.fixtures.go ui/context-menu/ContextMenuShortcut.fixtures.go
ContextMenuSub.fixtures.go ui/context-menu/ContextMenuSub.fixtures.go
ContextMenuSubContent.fixtures.go ui/context-menu/ContextMenuSubContent.fixtures.go
ContextMenuSubTrigger.fixtures.go ui/context-menu/ContextMenuSubTrigger.fixtures.go
ContextMenuTrigger.fixtures.go ui/context-menu/ContextMenuTrigger.fixtures.go
styles.go ui/context-menu/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/context-menu/ContextMenu.gx
package contextmenu

props {
  // Id is the id of the root element. The trigger opens the menu by this id.
  Id       string
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the menu items.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div id={p.Id} popover="auto" role="menu" data-gx-dismiss data-gx-roving="nowrap" data-gx-place="right start 2" class={gx.Cx("fixed z-50 m-0 min-w-[8rem] origin-top-left overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-hidden 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", p.Class)} {...p.Attrs}>{p.Children}</div>
ui/context-menu/ContextMenuCheckboxItem.gx
package contextmenu

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-sm 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/context-menu/ContextMenuGroup.gx
package contextmenu

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/context-menu/ContextMenuItem.gx
package contextmenu

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/context-menu/ContextMenuLabel.gx
package contextmenu

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 text-foreground", insetClass[p.Inset], p.Class)} {...p.Attrs}>{p.Children}</div>
ui/context-menu/ContextMenuLink.gx
package contextmenu

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/context-menu/ContextMenuRadioGroup.gx
package contextmenu

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/context-menu/ContextMenuRadioItem.gx
package contextmenu

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-sm 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/context-menu/ContextMenuSeparator.gx
package contextmenu

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/context-menu/ContextMenuShortcut.gx
package contextmenu

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/context-menu/ContextMenuSub.gx
package contextmenu

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/context-menu/ContextMenuSubContent.gx
package contextmenu

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/context-menu/ContextMenuSubTrigger.gx
package contextmenu

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-hidden 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", insetClass[p.Inset], p.Class)} {...rovingItem(p.Disabled)} {...p.Attrs}>
  {p.Children}
  <icons.ChevronRight class="ml-auto" />
</button>
ui/context-menu/ContextMenuTrigger.gx
package contextmenu

props {
  // Id is the id of the menu that a right click on the trigger opens.
  Id       string
  // Class adds classes to the root element. The trigger has no style of its own.
  Class    string = ""
  // Children is the content of the area that takes the right click.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div data-gx-contextmenu={"#" + p.Id} class={p.Class} {...p.Attrs}>{p.Children}</div>
ui/context-menu/ContextMenu.fixtures.go
package contextmenu

import "github.com/alternayte/gx"

var ContextMenuFixtures = gx.Fixtures[ContextMenuProps]{
	"Menu": {Id: "demo-context", Class: "w-52", Children: gx.Frag(
		ContextMenuItem(ContextMenuItemProps{Children: gx.Frag(
			gx.Text("Copy"),
			ContextMenuShortcut(ContextMenuShortcutProps{Children: gx.Text("⌘C")}),
		)}),
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Cut")}),
		ContextMenuSeparator(ContextMenuSeparatorProps{}),
		ContextMenuCheckboxItem(ContextMenuCheckboxItemProps{Name: "bookmarks", Checked: true, Children: gx.Text("Show bookmarks")}),
		ContextMenuSeparator(ContextMenuSeparatorProps{}),
		ContextMenuRadioGroup(ContextMenuRadioGroupProps{Label: "People", Children: gx.Frag(
			ContextMenuLabel(ContextMenuLabelProps{Inset: true, Children: gx.Text("People")}),
			ContextMenuRadioItem(ContextMenuRadioItemProps{Name: "demo-context-person", Value: "ada", Checked: true, Children: gx.Text("Ada")}),
			ContextMenuRadioItem(ContextMenuRadioItemProps{Name: "demo-context-person", Value: "grace", Children: gx.Text("Grace")}),
		)}),
		ContextMenuSeparator(ContextMenuSeparatorProps{}),
		ContextMenuLink(ContextMenuLinkProps{Href: gx.URL("/docs"), Children: gx.Text("Docs")}),
		ContextMenuItem(ContextMenuItemProps{Variant: Destructive, Children: gx.Text("Delete")}),
	)},
	"Sub": {Id: "demo-context-sub", Class: "w-52", Children: gx.Frag(
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Back")}),
		ContextMenuSub(ContextMenuSubProps{Children: gx.Frag(
			ContextMenuSubTrigger(ContextMenuSubTriggerProps{Children: gx.Text("More tools")}),
			ContextMenuSubContent(ContextMenuSubContentProps{Class: "w-48", Children: gx.Frag(
				ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Save page")}),
				ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Create shortcut")}),
				ContextMenuSeparator(ContextMenuSeparatorProps{}),
				ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Developer tools")}),
			)}),
		)}),
		ContextMenuSeparator(ContextMenuSeparatorProps{}),
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Reload")}),
	)},
}
ui/context-menu/ContextMenuCheckboxItem.fixtures.go
package contextmenu

import "github.com/alternayte/gx"

var ContextMenuCheckboxItemFixtures = gx.Fixtures[ContextMenuCheckboxItemProps]{
	"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")},
}

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

import "github.com/alternayte/gx"

var ContextMenuGroupFixtures = gx.Fixtures[ContextMenuGroupProps]{
	"Default": {Label: "Account", Children: gx.Frag(
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Profile")}),
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Billing")}),
	)},
}

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

import "github.com/alternayte/gx"

var ContextMenuItemFixtures = gx.Fixtures[ContextMenuItemProps]{
	"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")},
}

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

import "github.com/alternayte/gx"

var ContextMenuLabelFixtures = gx.Fixtures[ContextMenuLabelProps]{
	"Label": {Children: gx.Text("My account")},
	"Inset": {Inset: true, Children: gx.Text("My account")},
}
ui/context-menu/ContextMenuLink.fixtures.go
package contextmenu

import "github.com/alternayte/gx"

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

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

import "github.com/alternayte/gx"

var ContextMenuRadioGroupFixtures = gx.Fixtures[ContextMenuRadioGroupProps]{
	"Default": {Label: "Position", Children: gx.Frag(
		ContextMenuRadioItem(ContextMenuRadioItemProps{Name: "context-menu-group-position", Value: "top", Checked: true, Children: gx.Text("Top")}),
		ContextMenuRadioItem(ContextMenuRadioItemProps{Name: "context-menu-group-position", Value: "bottom", Children: gx.Text("Bottom")}),
	)},
}

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

import "github.com/alternayte/gx"

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

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

import "github.com/alternayte/gx"

var ContextMenuSeparatorFixtures = gx.Fixtures[ContextMenuSeparatorProps]{"Default": {}}
ui/context-menu/ContextMenuShortcut.fixtures.go
package contextmenu

import "github.com/alternayte/gx"

var ContextMenuShortcutFixtures = gx.Fixtures[ContextMenuShortcutProps]{"Default": {Children: gx.Text("⌘K")}}
ui/context-menu/ContextMenuSub.fixtures.go
package contextmenu

import "github.com/alternayte/gx"

var ContextMenuSubFixtures = gx.Fixtures[ContextMenuSubProps]{
	"Default": {Children: gx.Frag(
		ContextMenuSubTrigger(ContextMenuSubTriggerProps{Children: gx.Text("More tools")}),
		ContextMenuSubContent(ContextMenuSubContentProps{Children: gx.Frag(
			ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Save page")}),
			ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Create shortcut")}),
		)}),
	)},
}

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

import "github.com/alternayte/gx"

var ContextMenuSubContentFixtures = gx.Fixtures[ContextMenuSubContentProps]{
	"Content": {Class: "w-48", Children: gx.Frag(
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Save page")}),
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Create shortcut")}),
		ContextMenuSeparator(ContextMenuSeparatorProps{}),
		ContextMenuItem(ContextMenuItemProps{Children: gx.Text("Developer tools")}),
	)},
}

// ContextMenuSubContentWrap renders the content behind its trigger inside a menu,
// as a page uses it.
func ContextMenuSubContentWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "role", Value: "menu"}, {Key: "class", Value: "w-56"}},
		ContextMenuSub(ContextMenuSubProps{Children: gx.Frag(
			ContextMenuSubTrigger(ContextMenuSubTriggerProps{Children: gx.Text("More tools")}),
			n,
		)}),
	)
}
ui/context-menu/ContextMenuSubTrigger.fixtures.go
package contextmenu

import "github.com/alternayte/gx"

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

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

import "github.com/alternayte/gx"

var ContextMenuTriggerFixtures = gx.Fixtures[ContextMenuTriggerProps]{
	"Default": {
		Id:       "demo-context",
		Class:    "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-border text-sm",
		Children: gx.Text("Right-click here"),
	},
	"Sub": {
		Id:       "demo-context-sub",
		Class:    "flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-border text-sm",
		Children: gx.Text("Right-click for a sub-menu"),
	},
}
ui/context-menu/styles.go
package contextmenu

import "github.com/alternayte/gx"

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

// The variants of contextmenu.ContextMenuItem.
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 ContextMenuItemProps) 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)}
}

// 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, --popover, --popover-foreground, --foreground, --accent, --accent-foreground, --muted-foreground, --destructive.

Usage

<contextmenu.ContextMenuTrigger id="row-menu" class="rounded-md border border-dashed p-8">Right-click here</contextmenu.ContextMenuTrigger>
<contextmenu.ContextMenu id="row-menu" class="w-52">
  <contextmenu.ContextMenuItem>
    Copy
    <contextmenu.ContextMenuShortcut>⌘C</contextmenu.ContextMenuShortcut>
  </contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuCheckboxItem name="bookmarks" checked={true}>Show bookmarks</contextmenu.ContextMenuCheckboxItem>
  <contextmenu.ContextMenuRadioGroup label="People">
    <contextmenu.ContextMenuLabel inset={true}>People</contextmenu.ContextMenuLabel>
    <contextmenu.ContextMenuRadioItem name="person" value="ada" checked={true}>Ada</contextmenu.ContextMenuRadioItem>
    <contextmenu.ContextMenuRadioItem name="person" value="grace">Grace</contextmenu.ContextMenuRadioItem>
  </contextmenu.ContextMenuRadioGroup>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuSub>
    <contextmenu.ContextMenuSubTrigger>More tools</contextmenu.ContextMenuSubTrigger>
    <contextmenu.ContextMenuSubContent>
      <contextmenu.ContextMenuItem>Save page</contextmenu.ContextMenuItem>
      <contextmenu.ContextMenuItem>Developer tools</contextmenu.ContextMenuItem>
    </contextmenu.ContextMenuSubContent>
  </contextmenu.ContextMenuSub>
  <contextmenu.ContextMenuLink href={gx.URL("/docs")}>Docs</contextmenu.ContextMenuLink>
  <contextmenu.ContextMenuItem variant={contextmenu.Destructive}>Delete</contextmenu.ContextMenuItem>
</contextmenu.ContextMenu>

The trigger is an area with no style of its own; Class gives it one. The menu opens at the pointer and focus moves to the first item. It opens to the left of the pointer when the right side is too small, and it shifts up 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.

The menu fades and zooms in and out. A user who asks for reduced motion gets no transition.

ContextMenuSub holds one ContextMenuSubTrigger and one ContextMenuSubContent, 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

ContextMenu: Menu

<contextmenu.ContextMenu id="demo-context" class="w-52">
  <contextmenu.ContextMenuItem>
    Copy
    <contextmenu.ContextMenuShortcut>⌘C</contextmenu.ContextMenuShortcut>
  </contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuItem>Cut</contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuCheckboxItem name="bookmarks" checked>Show bookmarks</contextmenu.ContextMenuCheckboxItem>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuRadioGroup label="People">
    <contextmenu.ContextMenuLabel inset>People</contextmenu.ContextMenuLabel>
    <contextmenu.ContextMenuRadioItem name="demo-context-person" value="ada" checked>
      Ada
    </contextmenu.ContextMenuRadioItem>
    <contextmenu.ContextMenuRadioItem name="demo-context-person" value="grace">Grace</contextmenu.ContextMenuRadioItem>
  </contextmenu.ContextMenuRadioGroup>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuLink href={gx.URL("/docs")}>Docs</contextmenu.ContextMenuLink>
  <contextmenu.ContextMenuItem variant={contextmenu.Destructive}>Delete</contextmenu.ContextMenuItem>
</contextmenu.ContextMenu>
<contextmenu.ContextMenuTrigger id="demo-context" class="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-border text-sm">
  Right-click here
</contextmenu.ContextMenuTrigger>

ContextMenu: Sub

<contextmenu.ContextMenu id="demo-context-sub" class="w-52">
  <contextmenu.ContextMenuItem>Back</contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuSub>
    <contextmenu.ContextMenuSubTrigger>More tools</contextmenu.ContextMenuSubTrigger>
    <contextmenu.ContextMenuSubContent class="w-48">
      <contextmenu.ContextMenuItem>Save page</contextmenu.ContextMenuItem>
      <contextmenu.ContextMenuItem>Create shortcut</contextmenu.ContextMenuItem>
      <contextmenu.ContextMenuSeparator />
      <contextmenu.ContextMenuItem>Developer tools</contextmenu.ContextMenuItem>
    </contextmenu.ContextMenuSubContent>
  </contextmenu.ContextMenuSub>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuItem>Reload</contextmenu.ContextMenuItem>
</contextmenu.ContextMenu>
<contextmenu.ContextMenuTrigger id="demo-context-sub" class="flex h-[150px] w-[300px] items-center justify-center rounded-md border border-dashed border-border text-sm">
  Right-click for a sub-menu
</contextmenu.ContextMenuTrigger>

ContextMenuCheckboxItem: Checked

<contextmenu.ContextMenuCheckboxItem name="status-bar" checked>Status bar</contextmenu.ContextMenuCheckboxItem>

ContextMenuCheckboxItem: Unchecked

<contextmenu.ContextMenuCheckboxItem name="panel">Panel</contextmenu.ContextMenuCheckboxItem>

ContextMenuCheckboxItem: Disabled

<contextmenu.ContextMenuCheckboxItem name="activity" disabled>Activity bar</contextmenu.ContextMenuCheckboxItem>

ContextMenuGroup: Default

<contextmenu.ContextMenuGroup label="Account">
  <contextmenu.ContextMenuItem>Profile</contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuItem>Billing</contextmenu.ContextMenuItem>
</contextmenu.ContextMenuGroup>

ContextMenuItem: Item

<contextmenu.ContextMenuItem>Profile</contextmenu.ContextMenuItem>

ContextMenuItem: Inset

<contextmenu.ContextMenuItem inset>Profile</contextmenu.ContextMenuItem>

ContextMenuItem: Destructive

<contextmenu.ContextMenuItem variant={contextmenu.Destructive}>Delete</contextmenu.ContextMenuItem>

ContextMenuItem: Disabled

<contextmenu.ContextMenuItem disabled>Profile</contextmenu.ContextMenuItem>

ContextMenuLabel: Label

<contextmenu.ContextMenuLabel>My account</contextmenu.ContextMenuLabel>

ContextMenuLabel: Inset

<contextmenu.ContextMenuLabel inset>My account</contextmenu.ContextMenuLabel>
<contextmenu.ContextMenuLink href={gx.URL("/docs")}>Documentation</contextmenu.ContextMenuLink>

ContextMenuRadioGroup: Default

<contextmenu.ContextMenuRadioGroup label="Position">
  <contextmenu.ContextMenuRadioItem name="context-menu-group-position" value="top" checked>
    Top
  </contextmenu.ContextMenuRadioItem>
  <contextmenu.ContextMenuRadioItem name="context-menu-group-position" value="bottom">
    Bottom
  </contextmenu.ContextMenuRadioItem>
</contextmenu.ContextMenuRadioGroup>

ContextMenuRadioItem: Checked

<contextmenu.ContextMenuRadioItem name="context-menu-item-checked" value="top" checked>
  Top
</contextmenu.ContextMenuRadioItem>

ContextMenuRadioItem: Unchecked

<contextmenu.ContextMenuRadioItem name="context-menu-item-unchecked" value="bottom">
  Bottom
</contextmenu.ContextMenuRadioItem>

ContextMenuSeparator: Default

<contextmenu.ContextMenuSeparator />

ContextMenuShortcut: Default

<contextmenu.ContextMenuShortcut>⌘K</contextmenu.ContextMenuShortcut>

ContextMenuSub: Default

<contextmenu.ContextMenuSub>
  <contextmenu.ContextMenuSubTrigger>More tools</contextmenu.ContextMenuSubTrigger>
  <contextmenu.ContextMenuSubContent>
    <contextmenu.ContextMenuItem>Save page</contextmenu.ContextMenuItem>
    <contextmenu.ContextMenuItem>Create shortcut</contextmenu.ContextMenuItem>
  </contextmenu.ContextMenuSubContent>
</contextmenu.ContextMenuSub>

ContextMenuSubContent: Content

<contextmenu.ContextMenuSubContent class="w-48">
  <contextmenu.ContextMenuItem>Save page</contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuItem>Create shortcut</contextmenu.ContextMenuItem>
  <contextmenu.ContextMenuSeparator />
  <contextmenu.ContextMenuItem>Developer tools</contextmenu.ContextMenuItem>
</contextmenu.ContextMenuSubContent>

ContextMenuSubTrigger: Trigger

<contextmenu.ContextMenuSubTrigger>More tools</contextmenu.ContextMenuSubTrigger>

ContextMenuSubTrigger: Inset

<contextmenu.ContextMenuSubTrigger inset>More tools</contextmenu.ContextMenuSubTrigger>

ContextMenuSubTrigger: Disabled

<contextmenu.ContextMenuSubTrigger disabled>More tools</contextmenu.ContextMenuSubTrigger>

API reference

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

contextmenu.ContextMenu

Prop Type Default Description
Id string Required Id is the id of the root element. The trigger opens the menu by this id.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the menu items.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

contextmenu.ContextMenuCheckboxItem

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.

contextmenu.ContextMenuGroup

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.

contextmenu.ContextMenuItem

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.

contextmenu.ContextMenuLabel

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.

contextmenu.ContextMenuRadioGroup

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.

contextmenu.ContextMenuRadioItem

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.

contextmenu.ContextMenuSeparator

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.

contextmenu.ContextMenuShortcut

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.

contextmenu.ContextMenuSub

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.

contextmenu.ContextMenuSubContent

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.

contextmenu.ContextMenuSubTrigger

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.

contextmenu.ContextMenuTrigger

Prop Type Default Description
Id string Required Id is the id of the menu that a right click on the trigger opens.
Class string "" Class adds classes to the root element. The trigger has no style of its own.
Children gx.Node Required Children is the content of the area that takes the right click.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

Do and do not

Do

  • Attach the menu to the object it acts on.
  • Keep the same items as the visible row menu.
  • Give the radio items of one group the same Name.

Don't

  • Do not hide the only action behind a right click.
  • Do not open a context menu on a whole page.
  • Do not put an element between the trigger and the content of a sub-menu.

Keyboard

Key Action
Context menu key, Shift+F10 Opens the menu at the focused element of the area.
Up, Down Moves through the items. A disabled item is passed.
Home, End Moves to the first or last item.
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.
Space Runs the focused item. Toggles a checkbox or radio item.
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.
Escape Closes the menu.