Toast
Flexible toast component for notifications and feedback.
TailwindCSS
Vanilla JS
package showcase
import "github.com/axzilla/templui/internal/components/button"
templ ToastDefault() {
<div>
<form
class="flex flex-col gap-2"
hx-post="/docs/toast/demo"
hx-trigger="submit"
hx-target="#toast-container"
hx-vals='{
"title": "You have a new notification",
"description": "Test Notification",
"dismissible": "on"
}'
>
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Show Toast
}
</form>
<div id="toast-container"></div>
</div>
}
Installation
templui add toast
Copy and paste the following code into your project:
package toast import ( "github.com/axzilla/templui/internal/components/button" "github.com/axzilla/templui/internal/components/icon" "github.com/axzilla/templui/internal/utils" "strconv" ) type Variant string type Position string const ( VariantDefault Variant = "default" VariantSuccess Variant = "success" VariantError Variant = "error" VariantWarning Variant = "warning" VariantInfo Variant = "info" ) const ( PositionTopRight Position = "top-right" PositionTopLeft Position = "top-left" PositionTopCenter Position = "top-center" PositionBottomRight Position = "bottom-right" PositionBottomLeft Position = "bottom-left" PositionBottomCenter Position = "bottom-center" ) type Props struct { ID string Class string Attributes templ.Attributes Title string Description string Variant Variant Position Position Duration int Dismissible bool ShowIndicator bool Icon bool } templ Toast(props ...Props) { @Script() @ToastCSS() {{ var p Props }} if len(props) > 0 { {{ p = props[0] }} } if p.ID == "" { {{ p.ID = utils.RandomID() }} } {{ p = p.defaults() }} {{ isTop := p.Position == PositionTopRight || p.Position == PositionTopLeft || p.Position == PositionTopCenter }} {{ isBottom := p.Position == PositionBottomRight || p.Position == PositionBottomLeft || p.Position == PositionBottomCenter }} <div id={ p.ID } data-toast data-duration={ strconv.Itoa(p.Duration) } class={ utils.TwMerge( "z-50 fixed pointer-events-auto p-4", "opacity-0 transform transition-all duration-300 ease-out", utils.If(isTop, "top-0"), utils.If(isBottom, "bottom-0"), utils.If(isTop, "translate-y-4"), utils.If(isBottom, "-translate-y-4"), utils.If(p.Position == PositionTopRight || p.Position == PositionBottomRight, "right-0"), utils.If(p.Position == PositionTopLeft || p.Position == PositionBottomLeft, "left-0"), utils.If(p.Position == PositionTopCenter || p.Position == PositionBottomCenter, "left-1/2 -translate-x-1/2"), "w-full md:max-w-[420px]", p.Class, ) } { p.Attributes... } > <div class="w-full bg-primary-foreground rounded-lg shadow-xs border pt-5 pb-4 px-4 flex items-center justify-center relative overflow-hidden"> if p.ShowIndicator { @indicator(p) } if p.Icon { @toastIcon(p) } <span class="flex-1 min-w-0"> @title(p) @description(p) </span> if p.Dismissible { @dismissButton() } </div> </div> } templ indicator(p Props) { <div class="absolute top-0 left-0 right-0 h-1"> <div data-toast-progress class={ utils.TwMerge( "absolute inset-0", typeClass(p.Variant), ) } ></div> </div> } templ toastIcon(p Props) { if p.Variant == VariantSuccess { @icon.CircleCheck(icon.Props{Size: 22, Class: "text-green-500 mr-3 flex-shrink-0"}) } else if p.Variant == VariantError { @icon.CircleX(icon.Props{Size: 22, Class: "text-red-500 mr-3 flex-shrink-0"}) } else if p.Variant == VariantWarning { @icon.TriangleAlert(icon.Props{Size: 22, Class: "text-yellow-500 mr-3 flex-shrink-0"}) } else if p.Variant == VariantInfo { @icon.Info(icon.Props{Size: 22, Class: "text-blue-500 mr-3 flex-shrink-0"}) } } templ title(p Props) { if p.Title != "" { <p class="text-sm font-semibold truncate">{ p.Title }</p> } } templ description(p Props) { if p.Description != "" { <p class="text-sm opacity-90 mt-1">{ p.Description }</p> } } templ dismissButton() { @button.Button(button.Props{ Size: button.SizeIcon, Variant: button.VariantGhost, Attributes: templ.Attributes{ "aria-label": "Close", "data-toast-dismiss": "", "type": "button", }, }) { @icon.X(icon.Props{ Size: 18, Class: "opacity-75 hover:opacity-100", }) } } func (p Props) defaults() Props { if p.Variant == "" { p.Variant = VariantDefault } if p.Position == "" { p.Position = PositionBottomRight } if p.Duration == 0 { p.Duration = 3000 } return p } func typeClass(t Variant) string { switch t { case VariantDefault: return "bg-gray-500" case VariantSuccess: return "bg-green-500" case VariantError: return "bg-red-500" case VariantWarning: return "bg-yellow-500" case VariantInfo: return "bg-blue-500" default: return "" } } var cssHandle = templ.NewOnceHandle() templ ToastCSS() { @cssHandle.Once() { <style nonce={ templ.GetNonce(ctx) }> [data-toast].toast-enter { opacity: 0; /* Initial vertical offset is handled by classes in the component */ } [data-toast].toast-enter-active { opacity: 1; transform: translateY(0); /* Only handle vertical transition */ } [data-toast].toast-leave { opacity: 1; transform: translateY(0); /* Start leave from final vertical position */ } [data-toast].toast-leave-active { opacity: 0; /* Apply final vertical offset based on position */ } [data-toast][class*=" top-"].toast-leave-active { transform: translateY(1rem); /* Move down */ } [data-toast][class*=" bottom-"].toast-leave-active { transform: translateY(-1rem); /* Move up */ } </style> } } var handle = templ.NewOnceHandle() templ Script() { @handle.Once() { <script nonce={ templ.GetNonce(ctx) }> (function() { // IIFE if (typeof window.toastHandler === 'undefined') { window.toastHandler = true; window.toasts = new Map(); function initToast(toast) { if (window.toasts.has(toast)) return; const duration = parseInt(toast.dataset.duration || '0'); const progress = toast.querySelector('[data-toast-progress]'); const dismiss = toast.querySelector('[data-toast-dismiss]'); const state = { timer: null, remaining: duration, startTime: Date.now(), progress: progress, paused: false }; window.toasts.set(toast, state); function removeToast() { clearTimeout(state.timer); toast.classList.remove('toast-enter-active'); toast.classList.add('toast-leave-active'); toast.addEventListener('transitionend', () => { toast.remove(); window.toasts.delete(toast); }, { once: true }); } function startTimer(time) { if (time <= 0) return; clearTimeout(state.timer); state.startTime = Date.now(); state.remaining = time; state.paused = false; state.timer = setTimeout(removeToast, time); if (state.progress) { state.progress.style.transition = `width ${time}ms linear`; void state.progress.offsetWidth; state.progress.style.width = '0%'; } } function pauseTimer() { if (state.paused || state.remaining <= 0) return; clearTimeout(state.timer); state.remaining -= (Date.now() - state.startTime); state.paused = true; if (state.progress) { const width = window.getComputedStyle(state.progress).width; state.progress.style.transition = 'none'; state.progress.style.width = width; } } function resumeTimer() { if (!state.paused || state.remaining <= 0) return; startTimer(state.remaining); } if (duration > 0) { toast.addEventListener('mouseenter', pauseTimer); toast.addEventListener('mouseleave', resumeTimer); } if (dismiss) { dismiss.addEventListener('click', removeToast); } setTimeout(() => { toast.classList.add('toast-enter-active'); if (state.progress) { state.progress.style.width = '100%'; } startTimer(duration); }, 50); } function initAllComponents(root = document) { const toastsToInit = []; if (root instanceof Element && root.matches('[data-toast]')) { if (!window.toasts.has(root)) { toastsToInit.push(root); } } if (root && typeof root.querySelectorAll === 'function') { root.querySelectorAll('[data-toast]').forEach(toast => { if (!window.toasts.has(toast)) { toastsToInit.push(toast); } }); } toastsToInit.forEach(initToast); } const handleHtmxSwap = (event) => { const target = event.detail.elt if (target instanceof Element) { requestAnimationFrame(() => initAllComponents(target)); } }; 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.
// Template
templ UserForm() {
<form hx-post="/save" hx-target="#toast">
<input name="email" />
</form>
<div id="toast"></div>
}
// Handler
func Save(w http.ResponseWriter, r *http.Request) {
if err != nil {
components.Toast(components.ToastProps{
Text: err.Error(),
Variant: components.ToastVariantError,
}).Render(r.Context(), w)
}
}
// Template
templ UserForm(error string) {
if error != "" {
@components.Toast(components.ToastProps{
Text: error,
Variant: components.ToastVariantError,
})
}
<form method="POST">
<input name="email"/>
</form>
}
// Handler
func Save(w http.ResponseWriter, r *http.Request) {
if err != nil {
UserForm(err.Error()).Render(r.Context(), w)
}
}
Examples
Playground
package showcase
import (
"github.com/axzilla/templui/internal/components/button"
"github.com/axzilla/templui/internal/components/card"
"github.com/axzilla/templui/internal/components/form"
"github.com/axzilla/templui/internal/components/input"
"github.com/axzilla/templui/internal/components/label"
"github.com/axzilla/templui/internal/components/selectbox"
"github.com/axzilla/templui/internal/components/toggle"
)
templ ToastPlayground() {
<div class="w-full max-w-4xl mx-auto p-8">
<section class="mb-12">
@card.Card() {
@card.Content() {
<form
class="flex flex-col gap-2"
hx-post="/docs/toast/demo"
hx-trigger="submit"
hx-target="#toast-advanced-container"
>
// Message
@form.Item() {
@form.Label(form.LabelProps{
For: "title",
}) {
Message
}
@input.Input(input.Props{
Value: "You have a new notification",
ID: "title",
Name: "title",
})
}
// Description
@form.Item() {
@form.Label(form.LabelProps{
For: "description",
}) {
Description
}
@input.Input(input.Props{
Value: "Test Notification",
ID: "description",
Name: "description",
})
}
// Type
@form.Item() {
@form.Label(form.LabelProps{
For: "type",
}) {
Type
}
@selectbox.SelectBox() {
@selectbox.Trigger(selectbox.TriggerProps{
Name: "type",
ID: "type",
}) {
@selectbox.Value(selectbox.ValueProps{
Placeholder: "Default",
})
}
@selectbox.Content() {
@selectbox.Group() {
@selectbox.Item(selectbox.ItemProps{
Value: "default",
Selected: true,
}) {
Default
}
@selectbox.Item(selectbox.ItemProps{
Value: "success",
}) {
Success
}
@selectbox.Item(selectbox.ItemProps{
Value: "error",
}) {
Error
}
@selectbox.Item(selectbox.ItemProps{
Value: "warning",
}) {
Warning
}
@selectbox.Item(selectbox.ItemProps{
Value: "info",
}) {
Info
}
}
}
}
}
// Position
@form.Item() {
@form.Label(form.LabelProps{
For: "position",
}) {
Position
}
@selectbox.SelectBox() {
@selectbox.Trigger(selectbox.TriggerProps{
Name: "position",
ID: "position",
}) {
@selectbox.Value(selectbox.ValueProps{
Placeholder: "Bottom Right",
})
}
@selectbox.Content() {
@selectbox.Group() {
@selectbox.Item(selectbox.ItemProps{
Value: "top-right",
}) {
Top Right
}
@selectbox.Item(selectbox.ItemProps{
Value: "top-left",
}) {
Top Left
}
@selectbox.Item(selectbox.ItemProps{
Value: "top-center",
}) {
Top Center
}
@selectbox.Item(selectbox.ItemProps{
Value: "bottom-right",
Selected: true,
}) {
Bottom Right
}
@selectbox.Item(selectbox.ItemProps{
Value: "bottom-left",
}) {
Bottom Left
}
@selectbox.Item(selectbox.ItemProps{
Value: "bottom-center",
}) {
Bottom Center
}
}
}
}
}
// Duration
@form.Item() {
@form.Label(form.LabelProps{
For: "duration",
}) {
Duration (ms)
}
@input.Input(input.Props{
Type: input.TypeNumber,
Value: "3000",
ID: "duration",
Name: "duration",
})
}
// Options
@form.Item() {
@form.Label(form.LabelProps{
For: "dismissible",
}) {
Dismissible
}
@form.ItemFlex() {
@toggle.Toggle(toggle.Props{
Name: "dismissible",
Checked: true,
ID: "dismissible",
})
@label.Label(label.Props{
For: "dismissible",
}) {
Dimissible
}
}
@form.ItemFlex() {
@toggle.Toggle(toggle.Props{
Name: "icon",
Checked: true,
})
@label.Label(label.Props{
For: "icon",
}) {
Show Icon
}
}
@form.ItemFlex() {
@toggle.Toggle(toggle.Props{
ID: "indicator",
Name: "indicator",
Checked: true,
})
@label.Label(label.Props{
For: "indicator",
}) {
Show Indicator
}
}
}
// Submit
@button.Button(button.Props{
Type: button.TypeSubmit,
Class: "w-full",
}) {
Show Toast
}
</form>
}
}
</section>
<div id="toast-advanced-container"></div>
</div>
}