Toast
Flexible toast component for notifications and feedback.
TailwindCSS
Alpine.js
package showcase
import "github.com/axzilla/templui/components"
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"
}'
>
@components.Button(components.ButtonProps{
Type: "submit",
}) {
Show Toast
}
</form>
<div id="toast-container"></div>
</div>
}
package components
import (
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type ToastVariant string
type ToastPosition string
const (
ToastVariantDefault ToastVariant = "default"
ToastVariantSuccess ToastVariant = "success"
ToastVariantError ToastVariant = "error"
ToastVariantWarning ToastVariant = "warning"
ToastVariantInfo ToastVariant = "info"
)
const (
ToastPositionTopRight ToastPosition = "top-right"
ToastPositionTopLeft ToastPosition = "top-left"
ToastPositionTopCenter ToastPosition = "top-center"
ToastPositionBottomRight ToastPosition = "bottom-right"
ToastPositionBottomLeft ToastPosition = "bottom-left"
ToastPositionBottomCenter ToastPosition = "bottom-center"
)
type ToastProps struct {
ID string
Class string
Attributes templ.Attributes
Title string
Description string
Variant ToastVariant
Position ToastPosition
Duration int
Dismissible bool
ShowIndicator bool
Icon bool
}
templ Toast(props ...ToastProps) {
{{ var p ToastProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
{{ p = p.withDefaults() }}
<div
id={ p.ID }
data-duration={ strconv.Itoa(p.Duration) }
x-data="toast"
@mouseenter="pauseTimer"
@mouseleave="resumeTimer"
x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
class={
utils.TwMerge(
"z-50 fixed pointer-events-auto p-4",
utils.If(p.Position == ToastPositionTopRight || p.Position == ToastPositionTopLeft || p.Position == ToastPositionTopCenter, "top-0"),
utils.If(p.Position == ToastPositionBottomRight || p.Position == ToastPositionBottomLeft || p.Position == ToastPositionBottomCenter, "bottom-0"),
utils.If(p.Position == ToastPositionTopRight || p.Position == ToastPositionBottomRight, "right-0"),
utils.If(p.Position == ToastPositionTopLeft || p.Position == ToastPositionBottomLeft, "left-0"),
utils.If(p.Position == ToastPositionTopCenter || p.Position == ToastPositionBottomCenter, "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 {
@toastIndicator(p)
}
if p.Icon {
@toastIcon(p)
}
<span class="flex-1 min-w-0">
@toastTitle(p)
@toastDescription(p)
</span>
if p.Dismissible {
@toastDismissButton()
}
</div>
</div>
}
templ toastIndicator(p ToastProps) {
<div class="absolute top-0 left-0 right-0 h-1">
<div
x-ref="progress"
class={
utils.TwMerge(
"absolute inset-0",
toastTypeClass(p.Variant),
),
}
></div>
</div>
}
templ toastIcon(p ToastProps) {
if p.Variant == ToastVariantSuccess {
@icons.CircleCheck(icons.IconProps{Size: 22, Class: "text-green-500 mr-3 flex-shrink-0"})
} else if p.Variant == ToastVariantError {
@icons.CircleX(icons.IconProps{Size: 22, Class: "text-red-500 mr-3 flex-shrink-0"})
} else if p.Variant == ToastVariantWarning {
@icons.TriangleAlert(icons.IconProps{Size: 22, Class: "text-yellow-500 mr-3 flex-shrink-0"})
} else if p.Variant == ToastVariantInfo {
@icons.Info(icons.IconProps{Size: 22, Class: "text-blue-500 mr-3 flex-shrink-0"})
}
}
templ toastTitle(p ToastProps) {
if p.Title != "" {
<p class="text-sm font-semibold truncate">{ p.Title }</p>
}
}
templ toastDescription(p ToastProps) {
if p.Description != "" {
<p class="text-sm opacity-90 mt-1">{ p.Description }</p>
}
}
templ toastDismissButton() {
@Button(ButtonProps{
Size: ButtonSizeIcon,
Variant: ButtonVariantGhost,
Attributes: templ.Attributes{
"aria-label": "Close",
"@click": "dismissToast",
"@mouseenter.stop": "",
"@mouseleave.stop": "",
"type": "button",
},
}) {
@icons.X(icons.IconProps{
Size: 18,
Class: "opacity-75 hover:opacity-100",
})
}
}
func (p ToastProps) withDefaults() ToastProps {
if p.Variant == "" {
p.Variant = ToastVariantDefault
}
if p.Position == "" {
p.Position = ToastPositionBottomRight
}
if p.Duration == 0 {
p.Duration = 3000
}
return p
}
func toastTypeClass(t ToastVariant) string {
switch t {
case ToastVariantDefault:
return "bg-gray-500"
case ToastVariantSuccess:
return "bg-green-500"
case ToastVariantError:
return "bg-red-500"
case ToastVariantWarning:
return "bg-yellow-500"
case ToastVariantInfo:
return "bg-blue-500"
default:
return ""
}
}
templ ToastScript() {
{{ handler := templ.NewOnceHandle() }}
@handler.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('toast', () => ({
show: true,
duration: 0,
timer: null,
init() {
this.duration = parseInt(this.$el.dataset.duration || 0);
this.startTimer();
if (this.duration > 0) {
const progress = this.$refs.progress;
if (progress) {
progress.style.transition = `width ${this.duration}ms linear`;
progress.style.width = '100%';
setTimeout(() => {
progress.style.width = '0%';
}, 10);
}
}
},
startTimer() {
if (this.duration <= 0) return;
this.timer = setTimeout(() => {
this.show = false;
}, this.duration);
},
pauseTimer() {
if (this.timer) clearTimeout(this.timer);
const progress = this.$refs.progress;
if (progress) {
const width = progress.getBoundingClientRect().width;
const total = progress.parentElement.getBoundingClientRect().width;
this.duration = (width / total) * this.duration;
progress.style.transition = "none";
progress.style.width = width + "px";
}
},
resumeTimer() {
const progress = this.$refs.progress;
if (progress) {
progress.style.transition = "width " + this.duration + "ms linear";
progress.style.width = "0";
}
this.startTimer();
},
dismissToast() {
this.show = false;
}
}))
})
</script>
}
}
Usage
1. Add the script to your page/layout:
// Option A: All components (recommended)
@utils.ComponentScripts()
// Option B: Just Toast
@components.ToastScript()
2. Use the component:
@components.Toast(components.ToastProps{})
// 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/components"
templ ToastPlayground() {
<div class="w-full max-w-4xl mx-auto p-8">
<section class="mb-12">
@components.Card() {
@components.CardContent() {
<form
class="flex flex-col gap-2"
hx-post="/docs/toast/demo"
hx-trigger="submit"
hx-target="#toast-advanced-container"
>
// Message
@components.FormItem() {
@components.Label(components.LabelProps{
For: "title",
}) {
Message
}
@components.Input(components.InputProps{
Value: "You have a new notification",
ID: "title",
Name: "title",
})
}
// Description
@components.FormItem() {
@components.Label(components.LabelProps{
For: "description",
}) {
Description
}
@components.Input(components.InputProps{
Value: "Test Notification",
ID: "description",
Name: "description",
})
}
// Type
@components.FormItem() {
@components.Label(components.LabelProps{
For: "type",
}) {
Type
}
@components.Select() {
@components.SelectTrigger(components.SelectTriggerProps{
Name: "type",
ID: "type",
}) {
@components.SelectValue(components.SelectValueProps{
Placeholder: "Default",
})
}
@components.SelectContent() {
@components.SelectGroup() {
@components.SelectItem(components.SelectItemProps{
Value: "default",
Selected: true,
}) {
Default
}
@components.SelectItem(components.SelectItemProps{
Value: "success",
}) {
Success
}
@components.SelectItem(components.SelectItemProps{
Value: "error",
}) {
Error
}
@components.SelectItem(components.SelectItemProps{
Value: "warning",
}) {
Warning
}
@components.SelectItem(components.SelectItemProps{
Value: "info",
}) {
Info
}
}
}
}
}
// Position
@components.FormItem() {
@components.Label(components.LabelProps{
For: "position",
}) {
Position
}
@components.Select() {
@components.SelectTrigger(components.SelectTriggerProps{
Name: "position",
ID: "position",
}) {
@components.SelectValue(components.SelectValueProps{
Placeholder: "Bottom Right",
})
}
@components.SelectContent() {
@components.SelectGroup() {
@components.SelectItem(components.SelectItemProps{
Value: "top-right",
}) {
Top Right
}
@components.SelectItem(components.SelectItemProps{
Value: "top-left",
}) {
Top Left
}
@components.SelectItem(components.SelectItemProps{
Value: "top-center",
}) {
Top Center
}
@components.SelectItem(components.SelectItemProps{
Value: "bottom-right",
Selected: true,
}) {
Bottom Right
}
@components.SelectItem(components.SelectItemProps{
Value: "bottom-left",
}) {
Bottom Left
}
@components.SelectItem(components.SelectItemProps{
Value: "bottom-center",
}) {
Bottom Center
}
}
}
}
}
// Duration
@components.FormItem() {
@components.Label(components.LabelProps{
For: "duration",
}) {
Duration (ms)
}
@components.Input(components.InputProps{
Type: "number",
Value: "3000",
ID: "duration",
Name: "duration",
})
}
// Options
@components.FormItem() {
@components.Label(components.LabelProps{
For: "dismissible",
}) {
Dismissible
}
@components.FormItemFlex() {
@components.Toggle(components.ToggleProps{
Name: "dismissible",
Checked: true,
ID: "dismissible",
})
@components.Label(components.LabelProps{
For: "dismissible",
}) {
Dimissible
}
}
@components.FormItemFlex() {
@components.Toggle(components.ToggleProps{
Name: "icon",
Checked: true,
})
@components.Label(components.LabelProps{
For: "icon",
}) {
Show Icon
}
}
@components.FormItemFlex() {
@components.Toggle(components.ToggleProps{
ID: "indicator",
Name: "indicator",
Checked: true,
})
@components.Label(components.LabelProps{
For: "indicator",
}) {
Show Indicator
}
}
}
// Submit
@components.Button(components.ButtonProps{
Type: "submit",
Class: "w-full",
}) {
Show Toast
}
</form>
}
}
</section>
<div id="toast-advanced-container"></div>
</div>
}
package components
import (
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type ToastVariant string
type ToastPosition string
const (
ToastVariantDefault ToastVariant = "default"
ToastVariantSuccess ToastVariant = "success"
ToastVariantError ToastVariant = "error"
ToastVariantWarning ToastVariant = "warning"
ToastVariantInfo ToastVariant = "info"
)
const (
ToastPositionTopRight ToastPosition = "top-right"
ToastPositionTopLeft ToastPosition = "top-left"
ToastPositionTopCenter ToastPosition = "top-center"
ToastPositionBottomRight ToastPosition = "bottom-right"
ToastPositionBottomLeft ToastPosition = "bottom-left"
ToastPositionBottomCenter ToastPosition = "bottom-center"
)
type ToastProps struct {
ID string
Class string
Attributes templ.Attributes
Title string
Description string
Variant ToastVariant
Position ToastPosition
Duration int
Dismissible bool
ShowIndicator bool
Icon bool
}
templ Toast(props ...ToastProps) {
{{ var p ToastProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
{{ p = p.withDefaults() }}
<div
id={ p.ID }
data-duration={ strconv.Itoa(p.Duration) }
x-data="toast"
@mouseenter="pauseTimer"
@mouseleave="resumeTimer"
x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
class={
utils.TwMerge(
"z-50 fixed pointer-events-auto p-4",
utils.If(p.Position == ToastPositionTopRight || p.Position == ToastPositionTopLeft || p.Position == ToastPositionTopCenter, "top-0"),
utils.If(p.Position == ToastPositionBottomRight || p.Position == ToastPositionBottomLeft || p.Position == ToastPositionBottomCenter, "bottom-0"),
utils.If(p.Position == ToastPositionTopRight || p.Position == ToastPositionBottomRight, "right-0"),
utils.If(p.Position == ToastPositionTopLeft || p.Position == ToastPositionBottomLeft, "left-0"),
utils.If(p.Position == ToastPositionTopCenter || p.Position == ToastPositionBottomCenter, "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 {
@toastIndicator(p)
}
if p.Icon {
@toastIcon(p)
}
<span class="flex-1 min-w-0">
@toastTitle(p)
@toastDescription(p)
</span>
if p.Dismissible {
@toastDismissButton()
}
</div>
</div>
}
templ toastIndicator(p ToastProps) {
<div class="absolute top-0 left-0 right-0 h-1">
<div
x-ref="progress"
class={
utils.TwMerge(
"absolute inset-0",
toastTypeClass(p.Variant),
),
}
></div>
</div>
}
templ toastIcon(p ToastProps) {
if p.Variant == ToastVariantSuccess {
@icons.CircleCheck(icons.IconProps{Size: 22, Class: "text-green-500 mr-3 flex-shrink-0"})
} else if p.Variant == ToastVariantError {
@icons.CircleX(icons.IconProps{Size: 22, Class: "text-red-500 mr-3 flex-shrink-0"})
} else if p.Variant == ToastVariantWarning {
@icons.TriangleAlert(icons.IconProps{Size: 22, Class: "text-yellow-500 mr-3 flex-shrink-0"})
} else if p.Variant == ToastVariantInfo {
@icons.Info(icons.IconProps{Size: 22, Class: "text-blue-500 mr-3 flex-shrink-0"})
}
}
templ toastTitle(p ToastProps) {
if p.Title != "" {
<p class="text-sm font-semibold truncate">{ p.Title }</p>
}
}
templ toastDescription(p ToastProps) {
if p.Description != "" {
<p class="text-sm opacity-90 mt-1">{ p.Description }</p>
}
}
templ toastDismissButton() {
@Button(ButtonProps{
Size: ButtonSizeIcon,
Variant: ButtonVariantGhost,
Attributes: templ.Attributes{
"aria-label": "Close",
"@click": "dismissToast",
"@mouseenter.stop": "",
"@mouseleave.stop": "",
"type": "button",
},
}) {
@icons.X(icons.IconProps{
Size: 18,
Class: "opacity-75 hover:opacity-100",
})
}
}
func (p ToastProps) withDefaults() ToastProps {
if p.Variant == "" {
p.Variant = ToastVariantDefault
}
if p.Position == "" {
p.Position = ToastPositionBottomRight
}
if p.Duration == 0 {
p.Duration = 3000
}
return p
}
func toastTypeClass(t ToastVariant) string {
switch t {
case ToastVariantDefault:
return "bg-gray-500"
case ToastVariantSuccess:
return "bg-green-500"
case ToastVariantError:
return "bg-red-500"
case ToastVariantWarning:
return "bg-yellow-500"
case ToastVariantInfo:
return "bg-blue-500"
default:
return ""
}
}
templ ToastScript() {
{{ handler := templ.NewOnceHandle() }}
@handler.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('toast', () => ({
show: true,
duration: 0,
timer: null,
init() {
this.duration = parseInt(this.$el.dataset.duration || 0);
this.startTimer();
if (this.duration > 0) {
const progress = this.$refs.progress;
if (progress) {
progress.style.transition = `width ${this.duration}ms linear`;
progress.style.width = '100%';
setTimeout(() => {
progress.style.width = '0%';
}, 10);
}
}
},
startTimer() {
if (this.duration <= 0) return;
this.timer = setTimeout(() => {
this.show = false;
}, this.duration);
},
pauseTimer() {
if (this.timer) clearTimeout(this.timer);
const progress = this.$refs.progress;
if (progress) {
const width = progress.getBoundingClientRect().width;
const total = progress.parentElement.getBoundingClientRect().width;
this.duration = (width / total) * this.duration;
progress.style.transition = "none";
progress.style.width = width + "px";
}
},
resumeTimer() {
const progress = this.$refs.progress;
if (progress) {
progress.style.transition = "width " + this.duration + "ms linear";
progress.style.width = "0";
}
this.startTimer();
},
dismissToast() {
this.show = false;
}
}))
})
</script>
}
}