Dropdown Menu
Floating menu for displaying a list of actions or options.
TailwindCSS
Alpine.js
package showcase
import "github.com/axzilla/templui/pkg/components"
templ DropdownMenuDefault() {
@components.DropdownMenu(components.DropdownMenuProps{
Items: []components.DropdownMenuItem{
{Label: "Profile", Value: "profile"},
{Label: "Settings", Value: "settings"},
{Label: "Logout", Value: "logout"},
},
})
}
package components
import (
"fmt"
"github.com/axzilla/templui/pkg/icons"
"github.com/axzilla/templui/pkg/utils"
"strings"
)
type DropdownMenuItem struct {
Label string // Display text
Value string // Optional value
Href string // Optional link URL
Target string // Optional link target
IconLeft templ.Component // Optional icon on the left
IconRight templ.Component // Optional icon on the right
SubItems []DropdownMenuItem // Nested submenu items
Disabled bool // Disables the item
Attributes templ.Attributes // Additional HTML attributesß
}
type DropdownMenuProps struct {
Items []DropdownMenuItem // Menu items
Trigger templ.Component // Custom trigger element
Class string // Additional CSS classes
Position string // Preferred placement
}
func (d DropdownMenuItem) modifierClasses() string {
classes := []string{}
if d.Disabled {
classes = append(classes, "text-muted-foreground cursor-not-allowed")
} else {
classes = append(classes, "text-foreground hover:bg-accent hover:text-accent-foreground")
}
return strings.Join(classes, " ")
}
templ renderMenuItem(item DropdownMenuItem, index int, depth int) {
if len(item.SubItems) > 0 {
<div class="relative group">
<button
class={
utils.TwMerge(
"w-full text-left flex items-center justify-between px-4 py-2 text-sm",
item.modifierClasses(),
),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
aria-haspopup="true"
aria-expanded="false"
disabled?={ item.Disabled }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
@item.IconRight
} else {
@icons.ChevronRight(icons.IconProps{Size: "16"})
}
</button>
if depth < 3 {
<div class="absolute left-full top-0 hidden group-hover:block">
<div class="py-1 bg-popover rounded-md shadow-lg border border-border">
for subIndex, subItem := range item.SubItems {
@renderMenuItem(subItem, subIndex, depth+1)
}
</div>
</div>
}
</div>
} else if item.Href != "" {
<a
href={ templ.SafeURL(item.Href) }
target={ item.Target }
class={
"px-4 py-2 text-sm flex items-center",
templ.KV("text-foreground hover:bg-accent hover:text-accent-foreground", !item.Disabled),
templ.KV("text-muted-foreground cursor-not-allowed", item.Disabled),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
<span class="ml-auto">
@item.IconRight
</span>
}
</a>
} else {
<button
class={
"w-full text-left flex items-center justify-between px-4 py-2 text-sm",
templ.KV("text-foreground hover:bg-accent hover:text-accent-foreground", !item.Disabled),
templ.KV("text-muted-foreground cursor-not-allowed", item.Disabled),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
disabled?={ item.Disabled }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
@item.IconRight
}
</button>
}
}
templ DropdownScript() {
{{ handler := templ.NewOnceHandle() }}
@handler.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
isOpen: false,
position: null,
verticalPosition: 'bottom',
init() {
this.position = this.$el.dataset.position;
},
updatePosition() {
if (!this.isOpen) return;
const menu = this.$refs.menu;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
if (this.position === 'left' && rect.left < 0) {
this.position = 'right';
} else if (this.position !== 'left' && rect.right > viewportWidth) {
this.position = 'left';
}
if (this.verticalPosition === 'bottom' && rect.bottom > viewportHeight) {
this.verticalPosition = 'top';
} else if (this.verticalPosition === 'top' && rect.top < 0) {
this.verticalPosition = 'bottom';
}
},
getPositionClass() {
const classes = [];
if (this.position === 'left') {
classes.push('right-0');
} else {
classes.push('left-0');
}
if (this.verticalPosition === 'top') {
classes.push('bottom-full mb-2');
} else {
classes.push('top-full mt-2');
}
return classes.join(' ');
},
setClose() {
this.isOpen = false;
},
setOpen() {
this.isOpen = true;
},
openMenu() {
this.isOpen = !this.isOpen;
if(this.isOpen) this.$nextTick(() => this.updatePosition());
}
}))
});
</script>
}
}
// Floating menu for displaying a list of actions or options.
templ DropdownMenu(props DropdownMenuProps) {
<div
x-data="dropdown"
@resize.window="updatePosition"
class={ utils.TwMerge("relative inline-block text-left", props.Class) }
data-position={ props.Position }
>
<div @click="openMenu">
if props.Trigger != nil {
@props.Trigger
} else {
@Button(ButtonProps{
Text: "Options",
Variant: "outline",
IconRight: icons.ChevronDown(icons.IconProps{Size: "16"}),
})
}
</div>
<div
x-ref="menu"
x-show="isOpen"
@click.away="setClose"
@keydown.escape.window="setClose"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class={
"absolute z-50 mt-2 w-56 rounded-md shadow-lg bg-popover ring-1 ring-black ring-opacity-5 focus:outline-none",
"border border-border",
}
x-bind:class="getPositionClass"
role="menu"
aria-orientation="vertical"
aria-labelledby="dropdown-menu-button"
tabindex="-1"
>
<div class="py-1" role="none">
for index, item := range props.Items {
@renderMenuItem(item, index, 0)
}
</div>
</div>
</div>
}
Usage
1. Add the script to your page/layout:
// Option A: All components (recommended)
@utils.ComponentScripts()
// Option B: Just DropdownMenu
@components.DropdownMenuScript()
2. Use the component:
@components.DropdownMenu(components.DropdownMenuProps{...})
Examples
With Icons
package showcase
import (
"github.com/axzilla/templui/pkg/components"
"github.com/axzilla/templui/pkg/icons"
)
templ DropdownMenuWithIcons() {
@components.DropdownMenu(components.DropdownMenuProps{
Trigger: components.Button(components.ButtonProps{
Text: "User Menu",
Variant: "outline",
IconLeft: icons.Menu(icons.IconProps{Size: "16"}),
}),
Position: "right",
Items: []components.DropdownMenuItem{
{
Label: "Profile",
IconLeft: icons.User(icons.IconProps{Size: "16"}),
Href: "/docs/components/dropdown-menu",
},
{
Label: "Settings",
IconLeft: icons.Settings(icons.IconProps{Size: "16"}),
Href: "/docs/components/dropdown-menu",
},
{
Label: "Logout",
IconLeft: icons.LogOut(icons.IconProps{Size: "16"}),
Value: "logout",
},
},
})
}
package components
import (
"fmt"
"github.com/axzilla/templui/pkg/icons"
"github.com/axzilla/templui/pkg/utils"
"strings"
)
type DropdownMenuItem struct {
Label string // Display text
Value string // Optional value
Href string // Optional link URL
Target string // Optional link target
IconLeft templ.Component // Optional icon on the left
IconRight templ.Component // Optional icon on the right
SubItems []DropdownMenuItem // Nested submenu items
Disabled bool // Disables the item
Attributes templ.Attributes // Additional HTML attributesß
}
type DropdownMenuProps struct {
Items []DropdownMenuItem // Menu items
Trigger templ.Component // Custom trigger element
Class string // Additional CSS classes
Position string // Preferred placement
}
func (d DropdownMenuItem) modifierClasses() string {
classes := []string{}
if d.Disabled {
classes = append(classes, "text-muted-foreground cursor-not-allowed")
} else {
classes = append(classes, "text-foreground hover:bg-accent hover:text-accent-foreground")
}
return strings.Join(classes, " ")
}
templ renderMenuItem(item DropdownMenuItem, index int, depth int) {
if len(item.SubItems) > 0 {
<div class="relative group">
<button
class={
utils.TwMerge(
"w-full text-left flex items-center justify-between px-4 py-2 text-sm",
item.modifierClasses(),
),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
aria-haspopup="true"
aria-expanded="false"
disabled?={ item.Disabled }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
@item.IconRight
} else {
@icons.ChevronRight(icons.IconProps{Size: "16"})
}
</button>
if depth < 3 {
<div class="absolute left-full top-0 hidden group-hover:block">
<div class="py-1 bg-popover rounded-md shadow-lg border border-border">
for subIndex, subItem := range item.SubItems {
@renderMenuItem(subItem, subIndex, depth+1)
}
</div>
</div>
}
</div>
} else if item.Href != "" {
<a
href={ templ.SafeURL(item.Href) }
target={ item.Target }
class={
"px-4 py-2 text-sm flex items-center",
templ.KV("text-foreground hover:bg-accent hover:text-accent-foreground", !item.Disabled),
templ.KV("text-muted-foreground cursor-not-allowed", item.Disabled),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
<span class="ml-auto">
@item.IconRight
</span>
}
</a>
} else {
<button
class={
"w-full text-left flex items-center justify-between px-4 py-2 text-sm",
templ.KV("text-foreground hover:bg-accent hover:text-accent-foreground", !item.Disabled),
templ.KV("text-muted-foreground cursor-not-allowed", item.Disabled),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
disabled?={ item.Disabled }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
@item.IconRight
}
</button>
}
}
templ DropdownScript() {
{{ handler := templ.NewOnceHandle() }}
@handler.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
isOpen: false,
position: null,
verticalPosition: 'bottom',
init() {
this.position = this.$el.dataset.position;
},
updatePosition() {
if (!this.isOpen) return;
const menu = this.$refs.menu;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
if (this.position === 'left' && rect.left < 0) {
this.position = 'right';
} else if (this.position !== 'left' && rect.right > viewportWidth) {
this.position = 'left';
}
if (this.verticalPosition === 'bottom' && rect.bottom > viewportHeight) {
this.verticalPosition = 'top';
} else if (this.verticalPosition === 'top' && rect.top < 0) {
this.verticalPosition = 'bottom';
}
},
getPositionClass() {
const classes = [];
if (this.position === 'left') {
classes.push('right-0');
} else {
classes.push('left-0');
}
if (this.verticalPosition === 'top') {
classes.push('bottom-full mb-2');
} else {
classes.push('top-full mt-2');
}
return classes.join(' ');
},
setClose() {
this.isOpen = false;
},
setOpen() {
this.isOpen = true;
},
openMenu() {
this.isOpen = !this.isOpen;
if(this.isOpen) this.$nextTick(() => this.updatePosition());
}
}))
});
</script>
}
}
// Floating menu for displaying a list of actions or options.
templ DropdownMenu(props DropdownMenuProps) {
<div
x-data="dropdown"
@resize.window="updatePosition"
class={ utils.TwMerge("relative inline-block text-left", props.Class) }
data-position={ props.Position }
>
<div @click="openMenu">
if props.Trigger != nil {
@props.Trigger
} else {
@Button(ButtonProps{
Text: "Options",
Variant: "outline",
IconRight: icons.ChevronDown(icons.IconProps{Size: "16"}),
})
}
</div>
<div
x-ref="menu"
x-show="isOpen"
@click.away="setClose"
@keydown.escape.window="setClose"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class={
"absolute z-50 mt-2 w-56 rounded-md shadow-lg bg-popover ring-1 ring-black ring-opacity-5 focus:outline-none",
"border border-border",
}
x-bind:class="getPositionClass"
role="menu"
aria-orientation="vertical"
aria-labelledby="dropdown-menu-button"
tabindex="-1"
>
<div class="py-1" role="none">
for index, item := range props.Items {
@renderMenuItem(item, index, 0)
}
</div>
</div>
</div>
}
Advanced
package showcase
import (
"github.com/axzilla/templui/pkg/components"
"github.com/axzilla/templui/pkg/icons"
)
templ DropdownMenuAdvanced() {
@components.DropdownMenu(components.DropdownMenuProps{
Trigger: components.Button(components.ButtonProps{
Text: "Advanced Menu",
Variant: "outline",
IconLeft: icons.Menu(icons.IconProps{Size: "16"}),
}),
Position: "left",
Items: []components.DropdownMenuItem{
{Label: "File", SubItems: []components.DropdownMenuItem{
{Label: "New", Value: "new"},
{Label: "Open", Value: "open"},
{Label: "Save", Value: "save"},
}},
{Label: "Edit", SubItems: []components.DropdownMenuItem{
{Label: "Cut", Value: "cut"},
{Label: "Copy", Value: "copy"},
{Label: "Paste", Value: "paste"},
}},
{Label: "View", Disabled: true},
{Label: "Help", Href: "https://github.com/axzilla/templui", Target: "_blank"},
},
})
}
package components
import (
"fmt"
"github.com/axzilla/templui/pkg/icons"
"github.com/axzilla/templui/pkg/utils"
"strings"
)
type DropdownMenuItem struct {
Label string // Display text
Value string // Optional value
Href string // Optional link URL
Target string // Optional link target
IconLeft templ.Component // Optional icon on the left
IconRight templ.Component // Optional icon on the right
SubItems []DropdownMenuItem // Nested submenu items
Disabled bool // Disables the item
Attributes templ.Attributes // Additional HTML attributesß
}
type DropdownMenuProps struct {
Items []DropdownMenuItem // Menu items
Trigger templ.Component // Custom trigger element
Class string // Additional CSS classes
Position string // Preferred placement
}
func (d DropdownMenuItem) modifierClasses() string {
classes := []string{}
if d.Disabled {
classes = append(classes, "text-muted-foreground cursor-not-allowed")
} else {
classes = append(classes, "text-foreground hover:bg-accent hover:text-accent-foreground")
}
return strings.Join(classes, " ")
}
templ renderMenuItem(item DropdownMenuItem, index int, depth int) {
if len(item.SubItems) > 0 {
<div class="relative group">
<button
class={
utils.TwMerge(
"w-full text-left flex items-center justify-between px-4 py-2 text-sm",
item.modifierClasses(),
),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
aria-haspopup="true"
aria-expanded="false"
disabled?={ item.Disabled }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
@item.IconRight
} else {
@icons.ChevronRight(icons.IconProps{Size: "16"})
}
</button>
if depth < 3 {
<div class="absolute left-full top-0 hidden group-hover:block">
<div class="py-1 bg-popover rounded-md shadow-lg border border-border">
for subIndex, subItem := range item.SubItems {
@renderMenuItem(subItem, subIndex, depth+1)
}
</div>
</div>
}
</div>
} else if item.Href != "" {
<a
href={ templ.SafeURL(item.Href) }
target={ item.Target }
class={
"px-4 py-2 text-sm flex items-center",
templ.KV("text-foreground hover:bg-accent hover:text-accent-foreground", !item.Disabled),
templ.KV("text-muted-foreground cursor-not-allowed", item.Disabled),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
<span class="ml-auto">
@item.IconRight
</span>
}
</a>
} else {
<button
class={
"w-full text-left flex items-center justify-between px-4 py-2 text-sm",
templ.KV("text-foreground hover:bg-accent hover:text-accent-foreground", !item.Disabled),
templ.KV("text-muted-foreground cursor-not-allowed", item.Disabled),
}
role="menuitem"
tabindex="-1"
id={ fmt.Sprintf("menu-item-%d", index) }
disabled?={ item.Disabled }
{ item.Attributes... }
>
<span class="flex items-center gap-2">
if item.IconLeft != nil {
@item.IconLeft
}
{ item.Label }
</span>
if item.IconRight != nil {
@item.IconRight
}
</button>
}
}
templ DropdownScript() {
{{ handler := templ.NewOnceHandle() }}
@handler.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
isOpen: false,
position: null,
verticalPosition: 'bottom',
init() {
this.position = this.$el.dataset.position;
},
updatePosition() {
if (!this.isOpen) return;
const menu = this.$refs.menu;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
if (this.position === 'left' && rect.left < 0) {
this.position = 'right';
} else if (this.position !== 'left' && rect.right > viewportWidth) {
this.position = 'left';
}
if (this.verticalPosition === 'bottom' && rect.bottom > viewportHeight) {
this.verticalPosition = 'top';
} else if (this.verticalPosition === 'top' && rect.top < 0) {
this.verticalPosition = 'bottom';
}
},
getPositionClass() {
const classes = [];
if (this.position === 'left') {
classes.push('right-0');
} else {
classes.push('left-0');
}
if (this.verticalPosition === 'top') {
classes.push('bottom-full mb-2');
} else {
classes.push('top-full mt-2');
}
return classes.join(' ');
},
setClose() {
this.isOpen = false;
},
setOpen() {
this.isOpen = true;
},
openMenu() {
this.isOpen = !this.isOpen;
if(this.isOpen) this.$nextTick(() => this.updatePosition());
}
}))
});
</script>
}
}
// Floating menu for displaying a list of actions or options.
templ DropdownMenu(props DropdownMenuProps) {
<div
x-data="dropdown"
@resize.window="updatePosition"
class={ utils.TwMerge("relative inline-block text-left", props.Class) }
data-position={ props.Position }
>
<div @click="openMenu">
if props.Trigger != nil {
@props.Trigger
} else {
@Button(ButtonProps{
Text: "Options",
Variant: "outline",
IconRight: icons.ChevronDown(icons.IconProps{Size: "16"}),
})
}
</div>
<div
x-ref="menu"
x-show="isOpen"
@click.away="setClose"
@keydown.escape.window="setClose"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class={
"absolute z-50 mt-2 w-56 rounded-md shadow-lg bg-popover ring-1 ring-black ring-opacity-5 focus:outline-none",
"border border-border",
}
x-bind:class="getPositionClass"
role="menu"
aria-orientation="vertical"
aria-labelledby="dropdown-menu-button"
tabindex="-1"
>
<div class="py-1" role="none">
for index, item := range props.Items {
@renderMenuItem(item, index, 0)
}
</div>
</div>
</div>
}