Modal
Dialog overlay that requires user attention or interaction.
TailwindCSS
Vanilla JS
Are you absolutely sure?
This action cannot be undone. This will permanently delete your account and remove your data from our servers.
package showcase
import (
"github.com/axzilla/templui/internal/components/button"
"github.com/axzilla/templui/internal/components/modal"
)
templ ModalDefault() {
@modal.Trigger(modal.TriggerProps{
ModalID: "default-modal",
}) {
@button.Button() {
Open Modal
}
}
@modal.Modal(modal.Props{
ID: "default-modal",
Class: "max-w-md",
}) {
@modal.Header() {
Are you absolutely sure?
}
@modal.Body() {
This action cannot be undone. This will permanently delete your account and remove your data from our servers.
}
@modal.Footer() {
<div class="flex gap-2">
@modal.Close(modal.CloseProps{
ModalID: "default-modal",
}) {
@button.Button() {
Cancel
}
}
@modal.Close(modal.CloseProps{
ModalID: "default-modal",
}) {
@button.Button(button.Props{
Variant: button.VariantSecondary,
}) {
Continue
}
}
</div>
}
}
}
Installation
templui add modal
Copy and paste the following code into your project:
package modal import ( "github.com/axzilla/templui/internal/utils" "strconv" ) type Props struct { ID string Class string Attributes templ.Attributes DisableClickAway bool DisableESC bool } type TriggerProps struct { ID string Class string Attributes templ.Attributes Disabled bool ModalID string // ID of the modal to trigger } type CloseProps struct { ID string Class string Attributes templ.Attributes ModalID string // ID of the modal to close (optional, defaults to closest modal) } type HeaderProps struct { ID string Class string Attributes templ.Attributes } type BodyProps struct { ID string Class string Attributes templ.Attributes } type FooterProps struct { ID string Class string Attributes templ.Attributes } templ Modal(props ...Props) { @Script() {{ var p Props }} if len(props) > 0 { {{ p = props[0] }} } if p.ID == "" { {{ p.ID = "modal-" + utils.RandomID() }} } <div id={ p.ID } data-modal data-disable-click-away={ strconv.FormatBool(p.DisableClickAway) } data-disable-esc={ strconv.FormatBool(p.DisableESC) } class="modal-container fixed inset-0 z-50 flex items-center justify-center overflow-y-auto opacity-0 transition-opacity duration-300 ease-out hidden" aria-labelledby={ p.ID + "-title" } role="dialog" aria-modal="true" { p.Attributes... } > <div data-modal-backdrop class="fixed inset-0 bg-background/70 bg-opacity-50" aria-hidden="true"></div> <div id={ p.ID + "-content" } data-modal-content class={ utils.TwMerge( "modal-content relative bg-background rounded-lg border text-left overflow-hidden shadow-xl transform transition-all sm:my-8 w-full scale-95 opacity-0", // Base classes + transition start "duration-300 ease-out", // Enter duration p.Class, ), } > { children... } </div> </div> } templ Trigger(props ...TriggerProps) { {{ var p TriggerProps }} if len(props) > 0 { {{ p = props[0] }} } <span if p.ID != "" { id={ p.ID } } data-modal-trigger if p.ModalID != "" { data-modal-target-id={ p.ModalID } } class={ utils.TwMerge( utils.IfElse(p.Disabled, "cursor-not-allowed opacity-50", "cursor-pointer"), p.Class, ), } { p.Attributes... } > { children... } </span> } templ Close(props ...CloseProps) { {{ var p CloseProps }} if len(props) > 0 { {{ p = props[0] }} } <span if p.ID != "" { id={ p.ID } } data-modal-close if p.ModalID != "" { data-modal-target-id={ p.ModalID } } class={ utils.TwMerge("cursor-pointer", p.Class) } { p.Attributes... } > { children... } </span> } templ Header(props ...HeaderProps) { {{ var p HeaderProps }} if len(props) > 0 { {{ p = props[0] }} } <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge("px-4 pt-5 pb-4 sm:p-6 sm:pb-4 text-lg leading-6 font-medium text-foreground", p.Class) } { p.Attributes... } > <h3 class="text-lg leading-6 font-medium text-foreground" id={ p.ID + "-title" }> // Ensure title ID matches aria-labelledby { children... } </h3> </div> } templ Body(props ...BodyProps) { {{ var p BodyProps }} if len(props) > 0 { {{ p = props[0] }} } <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge("px-4 pt-5 pb-4 sm:p-6 sm:pb-4", p.Class) } { p.Attributes... } > { children... } </div> } templ Footer(props ...FooterProps) { {{ var p FooterProps }} if len(props) > 0 { {{ p = props[0] }} } <div if p.ID != "" { id={ p.ID } } class={ utils.TwMerge("px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse", p.Class) } { p.Attributes... } > { children... } </div> } var handle = templ.NewOnceHandle() templ Script() { @handle.Once() { <script nonce={ templ.GetNonce(ctx) }> if (typeof window.modalState === 'undefined') { window.modalState = { openModalId: null }; } (function() { // IIFE function closeModal(modal, immediate = false) { if (!modal || modal.style.display === 'none') return; const content = modal.querySelector('[data-modal-content]'); const modalId = modal.id; // Apply leaving transitions modal.classList.remove('opacity-100'); modal.classList.add('opacity-0'); if (content) { content.classList.remove('scale-100', 'opacity-100'); content.classList.add('scale-95', 'opacity-0'); } function hideModal() { modal.style.display = 'none'; if (window.modalState.openModalId === modalId) { window.modalState.openModalId = null; document.body.style.overflow = ''; } } if (immediate) { hideModal(); } else { setTimeout(hideModal, 300); } } function openModal(modal) { if (!modal) return; // Close any open modal first if (window.modalState.openModalId) { const openModal = document.getElementById(window.modalState.openModalId); if (openModal && openModal !== modal) { closeModal(openModal, true); } } const content = modal.querySelector('[data-modal-content]'); // Display and prepare for animation modal.style.display = 'flex'; // Store as currently open modal window.modalState.openModalId = modal.id; document.body.style.overflow = 'hidden'; // Force reflow before adding transition classes void modal.offsetHeight; // Start animations modal.classList.remove('opacity-0'); modal.classList.add('opacity-100'); if (content) { content.classList.remove('scale-95', 'opacity-0'); content.classList.add('scale-100', 'opacity-100'); // Focus first focusable element setTimeout(() => { const focusable = content.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); if (focusable) focusable.focus(); }, 50); } } function closeModalById(modalId, immediate = false) { const modal = document.getElementById(modalId); if (modal) closeModal(modal, immediate); } function openModalById(modalId) { const modal = document.getElementById(modalId); if (modal) openModal(modal); } function handleClickAway(e) { const openModalId = window.modalState.openModalId; if (!openModalId) return; const modal = document.getElementById(openModalId); if (!modal || modal.getAttribute('data-disable-click-away') === 'true') return; const content = modal.querySelector('[data-modal-content]'); const trigger = e.target.closest('[data-modal-trigger]'); if (content && !content.contains(e.target) && (!trigger || trigger.getAttribute('data-modal-target-id') !== openModalId)) { closeModal(modal); } } function handleEscKey(e) { if (e.key !== 'Escape' || !window.modalState.openModalId) return; const modal = document.getElementById(window.modalState.openModalId); if (modal && modal.getAttribute('data-disable-esc') !== 'true') { closeModal(modal); } } function initTrigger(trigger) { const targetId = trigger.getAttribute('data-modal-target-id'); if (!targetId) return; trigger.addEventListener('click', () => { if (!trigger.hasAttribute('disabled') && !trigger.classList.contains('opacity-50')) { openModalById(targetId); } }); } function initCloseButton(closeBtn) { closeBtn.addEventListener('click', () => { const targetId = closeBtn.getAttribute('data-modal-target-id'); if (targetId) { closeModalById(targetId); } else { const modal = closeBtn.closest('[data-modal]'); if (modal && modal.id) { closeModal(modal); } } }); } function initAllComponents(root = document) { if (root instanceof Element && root.matches('[data-modal-trigger]')) { initTrigger(root); } for (const trigger of root.querySelectorAll('[data-modal-trigger]')) { initTrigger(trigger); } if (root instanceof Element && root.matches('[data-modal-close]')) { initCloseButton(root); } for (const closeBtn of root.querySelectorAll('[data-modal-close]')) { initCloseButton(closeBtn); } } const handleHtmxSwap = (event) => { const target = event.detail.elt if (target instanceof Element) { requestAnimationFrame(() => initAllComponents(target)); } }; if (typeof window.modalEventsInitialized === 'undefined') { document.addEventListener('click', handleClickAway); document.addEventListener('keydown', handleEscKey); window.modalEventsInitialized = true; } initAllComponents(); document.addEventListener('DOMContentLoaded', () => initAllComponents()); document.body.addEventListener('htmx:afterSwap', handleHtmxSwap); document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap); })(); // End of IIFE </script> } }
Update the import paths to match your project setup.