# Tutorial: Analytics Dashboard
Build a complete, production-ready SaaS analytics dashboard using PhiaUI components in Phoenix LiveView. You will wire together a sidebar shell, KPI metric cards, a revenue chart, a sortable data table with server-side pagination, a command palette, and toast notifications — all with dark mode support.
## What you'll build
- A responsive app shell with collapsible sidebar navigation and a topbar
- Four KPI stat cards (MRR, Active Users, Churn Rate, NPS Score) using `metric_grid`
- A revenue area chart powered by ECharts via `phia_chart`
- A sortable, paginated user table using `data_grid` + `pagination`
- A Ctrl+K command palette with `command`
- Dark mode toggle in the topbar
- Toast notifications on save/export actions
## Prerequisites
- Elixir 1.17+ and Phoenix 1.8+ with LiveView 1.1+
- TailwindCSS v4 configured in your project
- Node.js 18+ (for ECharts via npm)
- PhiaUI added to your `mix.exs` dependencies
---
## Step 1 — Install PhiaUI and configure CSS
Add PhiaUI to `mix.exs`:
```elixir
def deps do
[
{:phia_ui, "~> 0.1.5"}
]
end
```
Run:
```bash
mix deps.get
mix phia.install
```
Import the PhiaUI CSS layer in `assets/css/app.css`:
```css
@import "../../deps/phia_ui/priv/static/theme.css";
@import "./phia-themes.css"; /* generated by mix phia.theme install */
```
Install a colour theme (optional, defaults to `zinc`):
```bash
mix phia.theme install blue
```
---
## Step 2 — Eject components
Eject only the components this dashboard needs:
```bash
mix phia.add shell sidebar topbar dark_mode_toggle stat_card metric_grid \
chart_shell phia_chart table data_grid pagination command toast button \
badge icon spinner
```
This copies each component's source file into `lib/my_app_web/components/phia/` so you own the code and can customise it freely.
> **Tip:** Run `mix phia.add --list` to see every available component and its dependencies before ejecting.
---
## Step 3 — Register JavaScript hooks in `app.js`
PhiaUI ships hook files into `priv/templates/js/hooks/` during `mix phia.install`. Import and register the hooks your dashboard needs:
```javascript
// assets/js/app.js
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
// PhiaUI hooks
import PhiaDarkMode from "./hooks/dark_mode";
import PhiaChart from "./hooks/chart";
import PhiaCommand from "./hooks/command";
import PhiaToast from "./hooks/toast";
import PhiaDropdownMenu from "./hooks/dropdown_menu";
const Hooks = {
PhiaDarkMode,
PhiaChart,
PhiaCommand,
PhiaToast,
PhiaDropdownMenu,
};
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
});
liveSocket.connect();
window.liveSocket = liveSocket;
```
---
## Step 4 — Create `DashboardLive`
Create `lib/my_app_web/live/dashboard_live.ex`:
```elixir
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
alias MyApp.Analytics
alias MyApp.Accounts
@page_size 10
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, "Analytics Dashboard")
|> assign(:stats, Analytics.get_dashboard_stats())
|> assign(:chart_series, Analytics.revenue_chart_series())
|> assign(:sort_by, :inserted_at)
|> assign(:sort_dir, :desc)
|> assign(:page, 1)
|> assign(:total_pages, ceil(Accounts.count_users() / @page_size))
|> assign(:command_items, command_items())
|> stream(:users, Accounts.list_users(sort: :inserted_at, dir: :desc, page: 1))
{:ok, socket}
end
@impl true
def handle_event("sort_users", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
new_dir =
if socket.assigns.sort_by == field_atom do
toggle_dir(socket.assigns.sort_dir)
else
:asc
end
users =
Accounts.list_users(
sort: field_atom,
dir: new_dir,
page: socket.assigns.page
)
socket =
socket
|> assign(:sort_by, field_atom)
|> assign(:sort_dir, new_dir)
|> stream(:users, users, reset: true)
{:noreply, socket}
end
@impl true
def handle_event("paginate", %{"page" => page_str}, socket) do
page = String.to_integer(page_str)
users =
Accounts.list_users(
sort: socket.assigns.sort_by,
dir: socket.assigns.sort_dir,
page: page
)
socket =
socket
|> assign(:page, page)
|> stream(:users, users, reset: true)
{:noreply, socket}
end
@impl true
def handle_event("export_csv", _params, socket) do
# Trigger background export job (e.g. via Oban)
Analytics.enqueue_csv_export(socket.assigns.current_user.id)
socket =
socket
|> put_flash(:info, "Your CSV export is being prepared. Check your email shortly.")
{:noreply, socket}
end
@impl true
def handle_event("command_navigate", %{"href" => href}, socket) do
{:noreply, push_navigate(socket, to: href)}
end
# ── Private ──────────────────────────────────────────────────────────────
defp toggle_dir(:asc), do: :desc
defp toggle_dir(:desc), do: :asc
defp command_items do
[
%{id: "dashboard", label: "Dashboard", icon: "hero-squares-2x2", href: "/dashboard"},
%{id: "users", label: "Users", icon: "hero-users", href: "/users"},
%{id: "billing", label: "Billing", icon: "hero-credit-card", href: "/billing"},
%{id: "settings", label: "Settings", icon: "hero-cog-6-tooth", href: "/settings"},
%{id: "export", label: "Export CSV", icon: "hero-arrow-down-tray", href: "#",
action: "export_csv"},
]
end
end
```
### Analytics context helpers
Add these to `lib/my_app/analytics.ex`:
```elixir
defmodule MyApp.Analytics do
@doc """
Returns the four KPI stats displayed on the dashboard header.
"""
def get_dashboard_stats do
[
%{
label: "Monthly Recurring Revenue",
value: "$48,295",
trend: +12.4,
icon: "hero-banknotes",
color: :green
},
%{
label: "Active Users",
value: "3,842",
trend: +5.1,
icon: "hero-users",
color: :blue
},
%{
label: "Churn Rate",
value: "2.3%",
trend: -0.8,
icon: "hero-arrow-trending-down",
color: :red
},
%{
label: "NPS Score",
value: "67",
trend: +3.0,
icon: "hero-star",
color: :violet
}
]
end
@doc """
Returns 12-month revenue series for the ECharts area chart.
"""
def revenue_chart_series do
months = ~w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec]
mrr = [32_000, 34_500, 33_800, 36_200, 37_900, 40_100,
41_500, 39_800, 43_200, 45_700, 46_900, 48_295]
arr = Enum.map(mrr, &(&1 * 12))
%{
type: "line",
categories: months,
series: [
%{name: "MRR ($)", data: mrr, area: true},
%{name: "ARR ($)", data: arr, area: false},
]
}
end
def enqueue_csv_export(user_id) do
# Oban / background job integration left to the application
:ok
end
end
```
---
## Step 5 — Build the shell layout template
Create `lib/my_app_web/live/dashboard_live.html.heex`:
```heex
<.shell>
<:sidebar>
<.sidebar brand="Acme Analytics" brand_href="/dashboard">
<.sidebar_section label="Main">
<.sidebar_item icon="hero-squares-2x2" href="/dashboard" active>
Dashboard
</.sidebar_item>
<.sidebar_item icon="hero-users" href="/users">
Users
</.sidebar_item>
<.sidebar_item icon="hero-credit-card" href="/billing">
Billing
</.sidebar_item>
</.sidebar_section>
<.sidebar_section label="System">
<.sidebar_item icon="hero-cog-6-tooth" href="/settings">
Settings
</.sidebar_item>
</.sidebar_section>
</.sidebar>
</:sidebar>
<:topbar>
<.topbar>
<:left>
<span class="text-sm font-medium text-muted-foreground">
Analytics Dashboard
</span>
</:left>
<:right>
<.button variant="ghost" size="sm" phx-click={JS.dispatch("phia:command:open")}>
<.icon name="hero-magnifying-glass" class="size-4 mr-2" />
Search...
<kbd class="ml-3 text-xs text-muted-foreground">⌘K</kbd>
</.button>
<.dark_mode_toggle />
<.button variant="outline" size="sm" phx-click="export_csv">
<.icon name="hero-arrow-down-tray" class="size-4 mr-2" />
Export
</.button>
</:right>
</.topbar>
</:topbar>
<:content>
<%# ── KPI cards ─────────────────────────────────────────────── %>
<section class="p-6 space-y-6">
<div>
<h1 class="text-2xl font-bold tracking-tight">Overview</h1>
<p class="text-sm text-muted-foreground mt-1">
Last updated: <%= Calendar.strftime(DateTime.utc_now(), "%B %d, %Y") %>
</p>
</div>
<.metric_grid columns={4}>
<.stat_card
:for={stat <- @stats}
label={stat.label}
value={stat.value}
trend={stat.trend}
icon={stat.icon}
color={stat.color}
/>
</.metric_grid>
<%# ── Revenue chart ───────────────────────────────────────── %>
<.chart_shell title="Revenue Trend" subtitle="Monthly vs Annual Recurring Revenue">
<.phia_chart
id="revenue-chart"
type={@chart_series.type}
categories={@chart_series.categories}
series={@chart_series.series}
height="320px"
/>
</.chart_shell>
<%# ── User table ──────────────────────────────────────────── %>
<div class="rounded-lg border bg-card">
<div class="flex items-center justify-between px-6 py-4 border-b">
<h2 class="text-base font-semibold">Users</h2>
<.badge variant="secondary"><%= @total_pages * @page_size %> total</.badge>
</div>
<.data_grid
id="users-grid"
rows={@streams.users}
sort_by={@sort_by}
sort_dir={@sort_dir}
on_sort="sort_users"
>
<:col label="Name" field={:name} sortable />
<:col label="Email" field={:email} sortable />
<:col label="Plan" field={:plan} >
<:cell :let={user}>
<.badge variant={plan_variant(user.plan)}><%= user.plan %></.badge>
</:cell>
</:col>
<:col label="Joined" field={:inserted_at} sortable />
<:col label="MRR" field={:mrr} sortable />
<:col label="Actions">
<:cell :let={user}>
<.button variant="ghost" size="icon" navigate={~p"/users/#{user.id}"}>
<.icon name="hero-eye" class="size-4" />
</.button>
</:cell>
</:col>
</.data_grid>
<div class="px-6 py-4 border-t">
<.pagination
page={@page}
total_pages={@total_pages}
on_change="paginate"
/>
</div>
</div>
</section>
<%# ── Command palette ────────────────────────────────────────── %>
<.command
id="dashboard-command"
placeholder="Search pages, actions..."
on_select="command_navigate"
>
<:item :for={item <- @command_items}
id={item.id}
label={item.label}
icon={item.icon}
value={item.href} />
</.command>
<%# ── Toast container ────────────────────────────────────────── %>
<.toast id="dashboard-toast" />
</:content>
</.shell>
```
---
## Step 6 — KPI cards with `metric_grid`
The `stat_card` component accepts `trend` as a signed float. A positive value renders a green upward arrow; a negative value renders a red downward arrow.
```heex
<.metric_grid columns={4}>
<.stat_card
label="Monthly Recurring Revenue"
value="$48,295"
trend={+12.4}
icon="hero-banknotes"
color={:green}
/>
<.stat_card
label="Active Users"
value="3,842"
trend={+5.1}
icon="hero-users"
color={:blue}
/>
<.stat_card
label="Churn Rate"
value="2.3%"
trend={-0.8}
icon="hero-arrow-trending-down"
color={:red}
/>
<.stat_card
label="NPS Score"
value="67"
trend={+3.0}
icon="hero-star"
color={:violet}
/>
</.metric_grid>
```
> **Tip:** On mobile, `metric_grid` collapses to a 2-column layout automatically via the responsive classes baked into the component. No extra CSS needed.
---
## Step 7 — Revenue area chart with `phia_chart`
The `phia_chart` component wraps ECharts. Pass `:area true` in a series entry to render a filled area beneath the line:
```heex
<.phia_chart
id="revenue-chart"
type="line"
categories={["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]}
series={[
%{name: "MRR ($)", data: [32_000,34_500,33_800,36_200,37_900,40_100,41_500,39_800,43_200,45_700,46_900,48_295], area: true},
%{name: "ARR ($)", data: [384_000,414_000,405_600,434_400,454_800,481_200,498_000,477_600,518_400,548_400,562_800,579_540], area: false},
]}
height="320px"
/>
```
The `PhiaChart` hook reads the `data-config` attribute serialised by the component and initialises the ECharts instance. Resizing is handled automatically by a `ResizeObserver`.
---
## Step 8 — Sortable data table with `data_grid` + `pagination`
The `data_grid` component fires a `phx-click` bound to `on_sort` when the user clicks a sortable column header. You receive the field name as a string and must convert it with `String.to_existing_atom/1`:
```elixir
def handle_event("sort_users", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
new_dir =
if socket.assigns.sort_by == field_atom,
do: toggle_dir(socket.assigns.sort_dir),
else: :asc
users = Accounts.list_users(sort: field_atom, dir: new_dir, page: socket.assigns.page)
{:noreply,
socket
|> assign(:sort_by, field_atom)
|> assign(:sort_dir, new_dir)
|> stream(:users, users, reset: true)}
end
```
The Ecto query in `Accounts.list_users/1` uses `offset`/`limit` for pagination:
```elixir
def list_users(opts \\ []) do
sort = Keyword.get(opts, :sort, :inserted_at)
dir = Keyword.get(opts, :dir, :desc)
page = Keyword.get(opts, :page, 1)
limit = 10
from(u in User,
order_by: [{^dir, ^sort}],
limit: ^limit,
offset: ^((page - 1) * limit)
)
|> Repo.all()
end
```
---
## Step 9 — Command palette (Ctrl+K)
The `command` component listens for the global keyboard shortcut via the `PhiaCommand` hook. Open it programmatically with `JS.dispatch("phia:command:open")`:
```elixir
defp command_items do
[
%{id: "dashboard", label: "Dashboard", icon: "hero-squares-2x2", href: "/dashboard"},
%{id: "users", label: "Users", icon: "hero-users", href: "/users"},
%{id: "billing", label: "Billing", icon: "hero-credit-card", href: "/billing"},
%{id: "settings", label: "Settings", icon: "hero-cog-6-tooth", href: "/settings"},
]
end
```
```heex
<.command
id="dashboard-command"
placeholder="Search pages, actions..."
on_select="command_navigate"
>
<:item :for={item <- @command_items}
id={item.id}
label={item.label}
icon={item.icon}
value={item.href} />
</.command>
```
Handle navigation:
```elixir
def handle_event("command_navigate", %{"href" => href}, socket) do
{:noreply, push_navigate(socket, to: href)}
end
```
---
## Step 10 — Toast notifications
The `toast` component is positioned fixed at the bottom-right by default. Trigger it via `put_flash/3` or directly via `push_event/3`:
```elixir
def handle_event("export_csv", _params, socket) do
Analytics.enqueue_csv_export(socket.assigns.current_user.id)
{:noreply, push_event(socket, "phia:toast", %{
message: "Export queued — check your email shortly.",
type: "success",
duration: 4000
})}
end
```
```heex
<%# Place once in your layout, outside any scrollable container %>
<.toast id="dashboard-toast" />
```
---
## Helper: badge variant for plan tier
```elixir
defp plan_variant("enterprise"), do: :default
defp plan_variant("pro"), do: :secondary
defp plan_variant("starter"), do: :outline
defp plan_variant(_), do: :ghost
```
---
## Final result
After completing all steps you will have a fully functional SaaS analytics dashboard that:
- Renders inside a responsive shell with a collapsible sidebar and a topbar
- Shows four real-time KPI cards that reflect data from your Analytics context
- Displays a 12-month revenue area chart powered by ECharts
- Lists users in a sortable, server-paginated data grid backed by Phoenix streams
- Lets users search and navigate with a Ctrl+K command palette
- Sends toast notifications for async operations like CSV export
- Supports dark mode without any layout shift on first paint
### Related components
- [phia_chart](data.md#phia_chart)
- [data_grid](data.md#data_grid)
- [pagination](data.md#pagination)
- [command](overlay.md#command)
- [metric_grid + stat_card](cards.md#stat_card)
- [shell + sidebar + topbar](layout.md#shell)
---