Dropdown Menu
Floating menu for displaying a list of actions or options.
TailwindCSS
Alpine.js
package showcase
import (
"github.com/axzilla/templui/components"
"github.com/axzilla/templui/icons"
)
templ DropdownMenuDefault() {
@components.DropdownMenu() {
@components.DropdownMenuTrigger() {
@components.Button(components.ButtonProps{
Variant: components.ButtonVariantOutline,
}) {
Open
}
}
@components.DropdownMenuContent(components.DropdownMenuContentProps{
Width: "w-56",
}) {
@components.DropdownMenuLabel() {
My Account
}
@components.DropdownMenuSeparator()
@components.DropdownMenuGroup() {
@components.DropdownMenuItem() {
Profile
@components.DropdownMenuShortcut() {
⇧⌘P
}
}
@components.DropdownMenuItem() {
Billing
@components.DropdownMenuShortcut() {
⌘B
}
}
@components.DropdownMenuItem() {
Settings
@components.DropdownMenuShortcut() {
⌘S
}
}
@components.DropdownMenuItem() {
Keyboard shortcuts
@components.DropdownMenuShortcut() {
⌘K
}
}
}
@components.DropdownMenuSeparator()
@components.DropdownMenuGroup() {
@components.DropdownMenuItem() {
Team
}
@components.DropdownMenuSub() {
@components.DropdownMenuSubTrigger() {
<span class="flex items-center">
@icons.Users(icons.IconProps{Size: 16, Class: "mr-2"})
Invite users
</span>
}
@components.DropdownMenuPortal() {
@components.DropdownMenuSubContent() {
@components.DropdownMenuItem() {
<span class="flex items-center">
@icons.Mail(icons.IconProps{Size: 16, Class: "mr-2"})
Email
</span>
}
@components.DropdownMenuItem() {
<span class="flex items-center">
@icons.MessageSquare(icons.IconProps{Size: 16, Class: "mr-2"})
Message
</span>
}
@components.DropdownMenuSeparator()
@components.DropdownMenuItem() {
More...
}
}
}
}
@components.DropdownMenuItem() {
New Team
@components.DropdownMenuShortcut() {
⌘+T
}
}
}
@components.DropdownMenuSeparator()
@components.DropdownMenuItem(components.DropdownMenuItemProps{
Href: "https://github.com",
Target: "_blank",
}) {
<span class="flex items-center">
@icons.Github(icons.IconProps{Size: 16, Class: "mr-2"})
GitHub
</span>
}
@components.DropdownMenuItem() {
<span class="flex items-center">
@icons.LifeBuoy(icons.IconProps{Size: 16, Class: "mr-2"})
Support
</span>
}
@components.DropdownMenuItem(components.DropdownMenuItemProps{
Disabled: true,
}) {
<span class="flex items-center">
@icons.Code(icons.IconProps{Size: 16, Class: "mr-2"})
API
</span>
}
@components.DropdownMenuSeparator()
@components.DropdownMenuItem() {
<span class="flex items-center">
@icons.LogOut(icons.IconProps{Size: 16, Class: "mr-2"})
Log out
</span>
@components.DropdownMenuShortcut() {
⇧⌘Q
}
}
}
}
}
package components
import "github.com/axzilla/templui/utils"
type DropdownMenuProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuContentProps struct {
ID string
Class string
Attributes templ.Attributes
Width string
MaxHeight string
Align string
Side string
}
type DropdownMenuGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuLabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuItemProps struct {
ID string
Class string
Attributes templ.Attributes
Disabled bool
Href string
Target string
}
type DropdownMenuSeparatorProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuShortcutProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuSubProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuSubTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuSubContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DropdownMenuPortalProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ DropdownMenu(props ...DropdownMenuProps) {
{{ var p DropdownMenuProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
<div
id={ p.ID }
x-data="dropdown"
class={ utils.TwMerge("relative inline-block text-left", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuTrigger(props ...DropdownMenuTriggerProps) {
{{ var p DropdownMenuTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
@click="toggleMenu"
class={ utils.TwMerge("inline-block", p.Class) }
data-trigger
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuContent(props ...DropdownMenuContentProps) {
{{ var p DropdownMenuContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
x-ref="panel"
x-show="isMenuOpen"
@click.outside="closeMenu"
@keydown.escape.window="closeMenu"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class={
utils.TwMerge(
"absolute z-50 rounded-md bg-popover p-1 shadow-md focus:outline-none overflow-auto",
"border border-border",
"min-w-[8rem]",
p.Width,
p.Class,
),
}
style="top: 100%; margin-top: 0.25rem; left: 0; max-height: var(--dropdown-max-height, 300px);"
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuGroup(props ...DropdownMenuGroupProps) {
{{ var p DropdownMenuGroupProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("py-1", p.Class) }
role="group"
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuLabel(props ...DropdownMenuLabelProps) {
{{ var p DropdownMenuLabelProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("px-2 py-1.5 text-sm font-semibold", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuItem(props ...DropdownMenuItemProps) {
{{ var p DropdownMenuItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
if p.Href != "" {
<a
id={ p.ID }
if p.Href != "" {
href={ templ.SafeURL(p.Href) }
}
if p.Target != "" {
target={ p.Target }
}
class={
utils.TwMerge(
"flex text-left items-center px-2 py-1.5 text-sm rounded-sm",
utils.If(!p.Disabled, "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default"),
utils.If(p.Disabled, "opacity-50 pointer-events-none"),
p.Class,
),
}
role="menuitem"
data-menu-item=""
{ p.Attributes... }
>
{ children... }
</a>
} else {
<button
id={ p.ID }
class={
utils.TwMerge(
"w-full text-left flex items-center justify-between px-2 py-1.5 text-sm rounded-sm",
utils.If(!p.Disabled, "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default"),
utils.If(p.Disabled, "opacity-50 pointer-events-none"),
p.Class,
),
}
role="menuitem"
data-menu-item=""
disabled?={ p.Disabled }
{ p.Attributes... }
>
{ children... }
</button>
}
}
templ DropdownMenuSeparator(props ...DropdownMenuSeparatorProps) {
{{ var p DropdownMenuSeparatorProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("h-px my-1 -mx-1 bg-muted", p.Class) }
role="separator"
{ p.Attributes... }
></div>
}
templ DropdownMenuShortcut(props ...DropdownMenuShortcutProps) {
{{ var p DropdownMenuShortcutProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("ml-auto text-xs tracking-widest opacity-60", p.Class) }
{ p.Attributes... }
>
{ children... }
</span>
}
templ DropdownMenuSub(props ...DropdownMenuSubProps) {
{{ var p DropdownMenuSubProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
x-data="dropdownSubmenu"
class={ utils.TwMerge("relative", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuSubTrigger(props ...DropdownMenuSubTriggerProps) {
{{ var p DropdownMenuSubTriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<button
if p.ID != "" {
id={ p.ID }
}
type="button"
x-ref="subTrigger"
data-submenu-trigger
class={
utils.TwMerge(
"w-full text-left flex items-center justify-between px-2 py-1.5 text-sm rounded-sm",
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default",
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
p.Class,
),
}
{ p.Attributes... }
>
<span>
{ children... }
</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-auto">
<path d="M6.5 3L11.5 8L6.5 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
}
templ DropdownMenuPortal(props ...DropdownMenuPortalProps) {
{{ var p DropdownMenuPortalProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("dropdown-portal", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuSubContent(props ...DropdownMenuSubContentProps) {
{{ var p DropdownMenuSubContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
x-ref="subContent"
data-submenu-content
class={
utils.TwMerge(
"z-[9999] min-w-[8rem] rounded-md border bg-popover p-1 shadow-lg",
p.Class,
),
}
style="position: fixed; display: none;"
{ p.Attributes... }
>
{ children... }
</div>
}
templ DropdownMenuScript() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
// Dropdown component with improved positioning
Alpine.data('dropdown', () => ({
isMenuOpen: false,
toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
if (this.isMenuOpen) {
this.$nextTick(() => {
this.adjustPosition();
window.addEventListener('resize', this.adjustPosition.bind(this));
});
} else {
window.removeEventListener('resize', this.adjustPosition.bind(this));
}
},
closeMenu() {
this.isMenuOpen = false;
window.removeEventListener('resize', this.adjustPosition.bind(this));
},
adjustPosition() {
const panel = this.$refs.panel;
if (!panel) return;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const triggerRect = this.$el.getBoundingClientRect();
// First set standard values for measurements
panel.style.top = '100%';
panel.style.left = '0';
panel.style.bottom = 'auto';
panel.style.right = 'auto';
panel.style.maxHeight = '';
// Re-measure after positioning
const panelRect = panel.getBoundingClientRect();
// Horizontal positioning
if (triggerRect.left + panelRect.width > viewportWidth) {
panel.style.left = 'auto';
panel.style.right = '0';
}
// Vertical positioning - more complex for different cases
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
// If there is enough space below
if (panelRect.height <= spaceBelow) {
// Position below the trigger
panel.style.top = '100%';
panel.style.bottom = 'auto';
panel.style.marginTop = '0.25rem';
panel.style.maxHeight = `${Math.max(100, spaceBelow - 10)}px`;
}
// If not enough space below, but enough space above
else if (panelRect.height <= spaceAbove) {
// Position above the trigger
panel.style.top = 'auto';
panel.style.bottom = '100%';
panel.style.marginTop = '0';
panel.style.marginBottom = '0.25rem';
panel.style.maxHeight = `${Math.max(100, spaceAbove - 10)}px`;
}
// If there isn't enough space either above or below
else {
// Decide where there is more space and use the maximum available space
if (spaceBelow >= spaceAbove) {
// If there's more space below than above
panel.style.top = '100%';
panel.style.bottom = 'auto';
panel.style.marginTop = '0.25rem';
panel.style.maxHeight = `${Math.max(100, spaceBelow - 10)}px`;
} else {
// If there's more space above than below
panel.style.top = 'auto';
panel.style.bottom = '100%';
panel.style.marginTop = '0';
panel.style.marginBottom = '0.25rem';
panel.style.maxHeight = `${Math.max(100, spaceAbove - 10)}px`;
}
}
// CSS variable for other components that need it
document.documentElement.style.setProperty('--dropdown-max-height', panel.style.maxHeight);
}
}));
// Submenu component with improved positioning and manual event setup
Alpine.data('dropdownSubmenu', () => ({
isSubmenuOpen: false,
closeTimer: null,
isMouseOverSubmenu: false,
init() {
// Find or create portal container
const portalContainer = document.getElementById('dropdown-portal-container');
if (!portalContainer) {
const container = document.createElement('div');
container.id = 'dropdown-portal-container';
container.style.position = 'fixed';
container.style.top = '0';
container.style.left = '0';
container.style.pointerEvents = 'none';
container.style.zIndex = '9999';
document.body.appendChild(container);
}
// Move sub-content to portal if portal exists
const portalElement = this.$el.querySelector('.dropdown-portal');
if (portalElement) {
const subContent = portalElement.querySelector('[data-submenu-content]');
if (subContent) {
document.getElementById('dropdown-portal-container').appendChild(subContent);
}
}
// Manual event setup
this.$nextTick(() => {
const self = this;
const trigger = this.$refs.subTrigger;
const content = this.$refs.subContent;
if (trigger) {
trigger.addEventListener('mouseenter', function() {
self.openSubmenu();
});
trigger.addEventListener('focus', function() {
self.openSubmenu();
});
trigger.addEventListener('mouseleave', function() {
self.startCloseTimer();
});
trigger.addEventListener('blur', function() {
self.startCloseTimer();
});
}
if (content) {
content.addEventListener('mouseenter', function() {
self.isMouseOverSubmenu = true;
self.openSubmenu();
});
content.addEventListener('mouseleave', function() {
self.isMouseOverSubmenu = false;
self.startCloseTimer();
});
}
});
},
openSubmenu() {
clearTimeout(this.closeTimer);
this.isSubmenuOpen = true;
const submenu = this.$refs.subContent;
if (submenu) {
submenu.style.display = 'block';
this.positionSubmenu();
}
},
startCloseTimer() {
const self = this;
// Delay to give the mouse time to move to the submenu
this.closeTimer = setTimeout(function() {
if (!self.isMouseOverSubmenu) {
self.closeSubmenu();
}
}, 300);
},
closeSubmenu() {
this.isSubmenuOpen = false;
const submenu = this.$refs.subContent;
if (submenu) {
submenu.style.display = 'none';
}
},
positionSubmenu() {
const submenu = this.$refs.subContent;
const trigger = this.$refs.subTrigger;
if (!submenu || !trigger) return;
// Make visible for correct measurements
submenu.style.display = 'block';
submenu.style.pointerEvents = 'auto';
const triggerRect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Default position: right of the trigger
submenu.style.top = `${triggerRect.top}px`;
submenu.style.left = `${triggerRect.right + 4}px`;
// Re-measure and adjust if needed
const submenuRect = submenu.getBoundingClientRect();
// Check horizontal positioning
if (triggerRect.right + submenuRect.width + 4 > viewportWidth) {
// If there's no space on the right, show left of the trigger
submenu.style.left = `${triggerRect.left - submenuRect.width - 4}px`;
}
// Check vertical positioning
const spaceBelow = viewportHeight - triggerRect.top;
const spaceAbove = triggerRect.bottom;
if (submenuRect.height > spaceBelow) {
// If there isn't enough space below...
if (submenuRect.height <= spaceAbove) {
// If there's enough space above, show submenu above the trigger
submenu.style.top = `${triggerRect.bottom - submenuRect.height}px`;
} else {
// If there isn't enough space either above or below, use maximum available height
const maxHeight = Math.max(spaceBelow, spaceAbove);
submenu.style.maxHeight = `${maxHeight - 20}px`; // 20px spacing
if (spaceBelow >= spaceAbove) {
// More space below than above
submenu.style.top = `${triggerRect.top}px`;
} else {
// More space above than below
submenu.style.top = `${Math.max(10, triggerRect.bottom - maxHeight + 10)}px`;
}
}
}
// Check if the submenu is still in the visible area
const updatedSubmenuRect = submenu.getBoundingClientRect();
// Ensure the top edge doesn't extend beyond the viewport
if (updatedSubmenuRect.top < 10) {
submenu.style.top = '10px';
}
// Ensure the bottom edge doesn't extend beyond the viewport
if (updatedSubmenuRect.bottom > viewportHeight - 10) {
if (updatedSubmenuRect.height > viewportHeight - 20) {
// If the submenu is larger than the viewport, make it scrollable
submenu.style.maxHeight = `${viewportHeight - 20}px`;
submenu.style.top = '10px';
submenu.style.overflowY = 'auto';
} else {
// Otherwise simply move it up
submenu.style.top = `${viewportHeight - updatedSubmenuRect.height - 10}px`;
}
}
}
}));
});
// Event listener for clicks on menu items to close all menus
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('click', function(event) {
// Check if a menu item was clicked
const menuItem = event.target.closest('[data-menu-item]');
if (menuItem) {
// Close all dropdown menus
document.querySelectorAll('[x-data="dropdown"]').forEach(dropdown => {
if (Alpine.$data) {
const instance = Alpine.$data(dropdown);
if (instance && typeof instance.closeMenu === 'function') {
instance.closeMenu();
}
} else {
// Fallback for older Alpine.js versions
setTimeout(function() {
const triggerButton = dropdown.querySelector('[data-trigger]');
if (triggerButton && dropdown.querySelector('[x-ref="panel"]').style.display !== 'none') {
triggerButton.click();
}
}, 10);
}
});
// Additionally close all submenus
document.querySelectorAll('[data-submenu-content]').forEach(submenu => {
submenu.style.display = 'none';
});
// Reset all submenu states in dropdownSubmenu components
document.querySelectorAll('[x-data="dropdownSubmenu"]').forEach(submenuComponent => {
if (Alpine.$data) {
const instance = Alpine.$data(submenuComponent);
if (instance) {
instance.isSubmenuOpen = false;
instance.isMouseOverSubmenu = false;
clearTimeout(instance.closeTimer);
if (typeof instance.closeSubmenu === 'function') {
instance.closeSubmenu();
}
}
}
});
}
});
});
</script>
}
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{...})