Loading states, alerts, progress indicators, and notification systems.
Table of Contents
alert
Non-interactive feedback banner. 4 variants with optional icon slot.
Variants: default, destructive, warning, success
<%!-- Default --%>
<.alert>
<.alert_title>Heads up!</.alert_title>
<.alert_description>Your trial expires in 3 days. Upgrade to keep access.</.alert_description>
</.alert>
<%!-- Destructive with icon --%>
<.alert variant="destructive">
<:icon><.icon name="alert-circle" /></:icon>
<.alert_title>Payment failed</.alert_title>
<.alert_description>Your card was declined. Please update your payment method.</.alert_description>
</.alert>
<%!-- Warning --%>
<.alert variant="warning">
<:icon><.icon name="alert-triangle" /></:icon>
<.alert_title>Storage limit approaching</.alert_title>
<.alert_description>You've used 90% of your 5GB quota.</.alert_description>
</.alert>
<%!-- Success --%>
<.alert variant="success">
<:icon><.icon name="check-circle" /></:icon>
<.alert_title>Published</.alert_title>
<.alert_description>Your post is now live and visible to subscribers.</.alert_description>
</.alert>
<%!-- Conditional render from flash --%>
<.alert :if={@flash["error"]} variant="destructive">
<.alert_title>Error</.alert_title>
<.alert_description><%= @flash["error"] %></.alert_description>
</.alert>alert_dialog
Modal confirmation dialog. Uses PhiaDialog hook. role="alertdialog" for screen readers.
Sub-components: alert_dialog_header/1, alert_dialog_title/1, alert_dialog_description/1, alert_dialog_footer/1, alert_dialog_cancel/1, alert_dialog_action/1
<%!-- Delete confirmation --%>
<.alert_dialog id="delete-confirm" open={@show_confirm}>
<.alert_dialog_header>
<.alert_dialog_title>Delete this item?</.alert_dialog_title>
<.alert_dialog_description>
This action cannot be undone. The item and all associated data will be permanently removed.
</.alert_dialog_description>
</.alert_dialog_header>
<.alert_dialog_footer>
<.alert_dialog_cancel phx-click="cancel-delete">Cancel</.alert_dialog_cancel>
<.alert_dialog_action variant="destructive" phx-click="confirm-delete" phx-value-id={@item_id}>
Delete
</.alert_dialog_action>
</.alert_dialog_footer>
</.alert_dialog>def handle_event("show-confirm", %{"id" => id}, socket) do
{:noreply, assign(socket, show_confirm: true, item_id: id)}
end
def handle_event("cancel-delete", _params, socket) do
{:noreply, assign(socket, show_confirm: false, item_id: nil)}
end
def handle_event("confirm-delete", %{"id" => id}, socket) do
MyApp.delete_item(id)
{:noreply, assign(socket, show_confirm: false, item_id: nil)}
endspinner
CSS SVG animated loading indicator. 5 sizes, role="status", aria-live="polite".
Sizes: :xs, :sm, :md (default), :lg, :xl
<.spinner />
<.spinner size="lg" class="text-primary" />
<.spinner size="sm" label="Loading users…" />
<%!-- In a button during loading --%>
<.button phx-click="save" disabled={@saving}>
<.spinner :if={@saving} size="xs" class="mr-2" />
<%= if @saving, do: "Saving…", else: "Save" %>
</.button>
<%!-- Full-page loading overlay --%>
<div :if={@loading} class="flex items-center justify-center h-64">
<div class="text-center space-y-2">
<.spinner size="xl" class="text-primary mx-auto" />
<p class="text-sm text-muted-foreground">Loading your data…</p>
</div>
</div>skeleton
animate-pulse placeholder blocks for content that is loading.
<%!-- Basic shapes --%>
<.skeleton class="h-4 w-48" />
<.skeleton class="h-10 w-10 rounded-full" />
<.skeleton class="h-24 w-full rounded-md" />
<%!-- User card skeleton --%>
<.card :if={@loading}>
<.card_header>
<div class="flex items-center gap-4">
<.skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<.skeleton class="h-4 w-32" />
<.skeleton class="h-3 w-24" />
</div>
</div>
</.card_header>
<.card_content class="space-y-2">
<.skeleton class="h-4 w-full" />
<.skeleton class="h-4 w-5/6" />
<.skeleton class="h-4 w-4/6" />
</.card_content>
</.card>
<%!-- Table skeleton --%>
<.table :if={@loading}>
<.table_body>
<.table_row :for={_ <- 1..5}>
<.table_cell><.skeleton class="h-4 w-28" /></.table_cell>
<.table_cell><.skeleton class="h-4 w-40" /></.table_cell>
<.table_cell><.skeleton class="h-6 w-16 rounded-full" /></.table_cell>
<.table_cell><.skeleton class="h-4 w-20" /></.table_cell>
</.table_row>
</.table_body>
</.table>progress
Horizontal progress bar. role="progressbar", aria-valuenow. Set value={nil} for indeterminate (animated).
<%!-- Determinate --%>
<.progress value={75} max={100} aria-label="Upload progress" />
<.progress value={@step} max={@total_steps} />
<%!-- Indeterminate --%>
<.progress aria-label="Loading…" />
<%!-- File upload with label --%>
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span>Uploading report.pdf</span>
<span><%= @upload_percent %>%</span>
</div>
<.progress value={@upload_percent} max={100} />
</div>circular_progress
SVG circular progress ring. Shows percentage label inside.
Attrs: value (0–100), size, stroke_width, color
<.circular_progress value={72} />
<.circular_progress value={@disk_usage} size={120} stroke_width={8} color="text-orange-500" />
<.circular_progress value={100} color="text-green-500" />
<%!-- In a stat card --%>
<.card>
<.card_content class="flex items-center gap-4 pt-4">
<.circular_progress value={@completion_rate} size={80} />
<div>
<p class="text-2xl font-bold"><%= @completion_rate %>%</p>
<p class="text-sm text-muted-foreground">Tasks completed</p>
</div>
</.card_content>
</.card>empty_state
Centered placeholder for empty lists, no search results, or onboarding.
Sub-components: use slots :icon, :title, :description, :action
<%!-- Empty list --%>
<.empty_state :if={@items == []}>
<:icon><.icon name="inbox" class="h-12 w-12 text-muted-foreground" /></:icon>
<:title>No items yet</:title>
<:description>Create your first item to get started.</:description>
<:action>
<.button phx-click="create-item">
<.icon name="plus" size="sm" /> Create item
</.button>
</:action>
</.empty_state>
<%!-- No search results --%>
<.empty_state :if={@search != "" and @results == []}>
<:icon><.icon name="search" class="h-12 w-12 text-muted-foreground" /></:icon>
<:title>No results for "<%= @search %>"</:title>
<:description>Try adjusting your search or filter to find what you're looking for.</:description>
<:action>
<.button variant="outline" phx-click="clear-search">Clear search</.button>
</:action>
</.empty_state>
<%!-- Error state --%>
<.empty_state>
<:icon><.icon name="alert-triangle" class="h-12 w-12 text-destructive" /></:icon>
<:title>Something went wrong</:title>
<:description>We couldn't load your data. Please try again.</:description>
<:action>
<.button phx-click="retry-load">Try again</.button>
</:action>
</.empty_state>step_tracker
Multi-step wizard progress indicator. Horizontal or vertical orientation.
Sub-components: step/1 with attrs: status (complete/active/upcoming), label, step (number), description
<%!-- Horizontal (default) --%>
<.step_tracker>
<.step status="complete" label="Account" step={1} />
<.step status="complete" label="Profile" step={2} />
<.step status="active" label="Billing" step={3} description="Enter payment details" />
<.step status="upcoming" label="Confirm" step={4} />
</.step_tracker>
<%!-- Vertical --%>
<.step_tracker orientation="vertical">
<.step status="complete" label="Choose plan" step={1} />
<.step status="active" label="Payment" step={2} description="Secure checkout" />
<.step status="upcoming" label="Activate" step={3} />
</.step_tracker># LiveView with step navigation
def handle_event("next-step", _params, socket) do
{:noreply, update(socket, :step, &min(&1 + 1, socket.assigns.total_steps))}
end
def handle_event("prev-step", _params, socket) do
{:noreply, update(socket, :step, &max(&1 - 1, 1))}
end
defp step_status(current, step_num) do
cond do
step_num < current -> "complete"
step_num == current -> "active"
true -> "upcoming"
end
endtoast
push_event-driven toast notification. Mount once in root.html.heex, trigger from any LiveView.
Hook: PhiaToast
Variants: default, success, destructive, warning, info
<%!-- Mount once in root.html.heex or app.html.heex --%>
<.toast id="toast-viewport" /># Trigger from any LiveView event handler
{:noreply, push_event(socket, "phia-toast", %{
title: "Saved",
description: "Your changes have been saved successfully.",
variant: "success",
duration_ms: 4000
})}
# Error toast
{:noreply, push_event(socket, "phia-toast", %{
title: "Error",
description: "Failed to save. Please try again.",
variant: "destructive"
})}
# Simple toast (no description)
{:noreply, push_event(socket, "phia-toast", %{title: "Copied to clipboard!"})}snackbar
Material-style bottom-center notification. Component-controlled (not push_event). Good for undo patterns.
Attrs: message, variant (default/success/error/warning), action_label, on_action
<.snackbar
:if={@snackbar_message}
message={@snackbar_message}
variant={@snackbar_variant}
action_label="Undo"
on_action="undo_delete"
/>def handle_event("delete-item", %{"id" => id}, socket) do
MyApp.soft_delete(id)
Process.send_after(self(), :clear_snackbar, 5000)
{:noreply, assign(socket,
snackbar_message: "Item deleted",
snackbar_variant: "default",
last_deleted_id: id
)}
end
def handle_event("undo_delete", _params, socket) do
MyApp.restore(socket.assigns.last_deleted_id)
{:noreply, assign(socket, snackbar_message: nil, last_deleted_id: nil)}
end
def handle_info(:clear_snackbar, socket) do
{:noreply, assign(socket, snackbar_message: nil)}
endsonner
Rich stacking toast notifications (Sonner-style). Push multiple toasts; they stack with animations.
Hook: PhiaSonner
<%!-- Mount once in root.html.heex --%>
<.sonner id="sonner-viewport" /># Types: "success", "error", "warning", "info", "default"
{:noreply, push_event(socket, "phia-sonner", %{
message: "Post published!",
type: "success"
})}
{:noreply, push_event(socket, "phia-sonner", %{
message: "Build failed",
description: "See logs for details.",
type: "error"
})}