Packages

shadcn/ui-inspired component library for Phoenix LiveView with eject-based distribution. CSS-first theme system, 829 components across 20+ categories — Calendar, Chart, Animation, DnD, Editor, Collaboration, Typography, Navigation, Background, Surface, and more.

Current section

Files

Jump to
phia_ui lib phia_ui components feedback skeleton.ex
Raw

lib/phia_ui/components/feedback/skeleton.ex

defmodule PhiaUi.Components.Skeleton do
@moduledoc """
Animated loading placeholder components (shimmer / skeleton screens).
Skeleton screens replace content areas while data is loading, preventing
layout shift and providing a smoother perceived loading experience than a
spinner. All components use Tailwind v4 `animate-pulse` — pure HEEx, no
JavaScript required.
## Sub-components
| Component | Purpose |
|-------------------|----------------------------------------------------------|
| `skeleton/1` | Base rectangle or circle placeholder block |
| `skeleton_text/1` | Stack of text-line placeholders simulating a paragraph |
| `skeleton_avatar/1`| Circular placeholder for avatar images |
| `skeleton_card/1` | Composite card-shaped placeholder (image + text lines) |
## Basic shapes
<%!-- Rectangle placeholder (for an image, header, or button) --%>
<.skeleton class="h-4 w-full" />
<%!-- Circle placeholder (for an avatar) --%>
<.skeleton shape={:circle} width="40px" height="40px" />
## Dashboard card loading skeletons
Replace stat cards with skeletons while data loads:
<%= if @loading do %>
<div class="grid grid-cols-3 gap-4">
<%= for _ <- 1..3 do %>
<.skeleton_card />
<% end %>
</div>
<% else %>
<.metric_grid>
<%= for stat <- @stats do %>
<.stat_card title={stat.title} value={stat.value} />
<% end %>
</.metric_grid>
<% end %>
## List item skeletons
Use `skeleton_avatar/1` with `skeleton_text/1` to match a user list:
<%= if @loading do %>
<div class="space-y-4">
<%= for _ <- 1..5 do %>
<div class="flex items-center gap-4">
<.skeleton_avatar size="10" />
<div class="flex-1">
<.skeleton_text lines={2} />
</div>
</div>
<% end %>
</div>
<% else %>
<%= for user <- @users do %>
<.user_row user={user} />
<% end %>
<% end %>
## Article / blog post skeleton
<div class="space-y-4 max-w-2xl">
<%!-- Hero image area --%>
<.skeleton class="h-56 w-full" />
<%!-- Title --%>
<.skeleton class="h-8 w-3/4" />
<%!-- Author line --%>
<div class="flex items-center gap-2">
<.skeleton_avatar size="8" />
<.skeleton class="h-4 w-32" />
</div>
<%!-- Body paragraphs --%>
<.skeleton_text lines={5} />
<.skeleton_text lines={4} />
</div>
## Table skeleton
Match the column widths of your real table:
<table>
<thead>...</thead>
<tbody>
<%= if @loading do %>
<%= for _ <- 1..8 do %>
<tr>
<td class="p-4"><.skeleton class="h-4 w-8" /></td>
<td class="p-4"><.skeleton class="h-4 w-40" /></td>
<td class="p-4"><.skeleton class="h-4 w-24" /></td>
<td class="p-4"><.skeleton class="h-4 w-20" /></td>
</tr>
<% end %>
<% else %>
...real rows...
<% end %>
</tbody>
</table>
"""
use Phoenix.Component
import PhiaUi.ClassMerger, only: [cn: 1]
# ---------------------------------------------------------------------------
# skeleton/1 — base placeholder block
# ---------------------------------------------------------------------------
attr(:shape, :atom,
values: [:rectangle, :circle],
default: :rectangle,
doc:
"Shape of the placeholder. `:rectangle` (default) for blocks and text lines; `:circle` for avatars."
)
attr(:width, :string,
default: nil,
doc:
"CSS width applied as an inline style (e.g. `\"200px\"`, `\"50%\"`). When `nil`, width is controlled by the `class` attribute."
)
attr(:height, :string,
default: nil,
doc:
"CSS height applied as an inline style (e.g. `\"48px\"`, `\"2rem\"`). When `nil`, height is controlled by the `class` attribute."
)
attr(:class, :string,
default: nil,
doc: "Additional CSS classes merged via `cn/1`. Use Tailwind `h-N w-N` to size the skeleton."
)
attr(:rest, :global, doc: "HTML attributes forwarded to the root `<div>` element.")
@doc """
Renders a single animated skeleton placeholder block.
Use `:rectangle` (default) for any block-level placeholder: image areas,
text lines, buttons, or card borders. Use `:circle` for avatar placeholders.
Size the skeleton with Tailwind classes via `:class`, or with explicit
pixel/rem values via `:width` and `:height`. Both can be used together.
## Examples
<%!-- Full-width text line --%>
<.skeleton class="h-4 w-full" />
<%!-- Fixed-size image placeholder --%>
<.skeleton class="h-40 w-full" />
<%!-- Circle with explicit pixel size --%>
<.skeleton shape={:circle} width="48px" height="48px" />
"""
def skeleton(assigns) do
~H"""
<div
class={cn(["animate-pulse bg-muted", shape_class(@shape), @class])}
{style_attrs(@width, @height)}
{@rest}
>
</div>
"""
end
# ---------------------------------------------------------------------------
# skeleton_text/1 — paragraph placeholder
# ---------------------------------------------------------------------------
attr(:lines, :integer,
default: 3,
doc:
"Number of text lines to render. Default: `3`. The last line is rendered at `w-3/4` to simulate natural text flow."
)
attr(:class, :string,
default: nil,
doc: "Additional CSS classes applied to the wrapper `<div>`."
)
@doc """
Renders a stack of skeleton lines simulating a paragraph of text.
The last line is always rendered at 75% width (`w-3/4`) to mimic the
natural way text rarely fills the entire last line of a paragraph.
## Example
<%!-- Simulates 4 lines of body text --%>
<.skeleton_text lines={4} />
"""
def skeleton_text(assigns) do
~H"""
<div class={cn(["space-y-2", @class])}>
<%= for i <- 1..@lines do %>
<%!-- Last line is narrower to match natural paragraph tail --%>
<.skeleton class={cn(["h-4", if(i == @lines, do: "w-3/4", else: "w-full")])} />
<% end %>
</div>
"""
end
# ---------------------------------------------------------------------------
# skeleton_avatar/1 — circular avatar placeholder
# ---------------------------------------------------------------------------
attr(:size, :string,
default: "10",
doc:
"Tailwind size number that maps to both `w-N` and `h-N`. For example, `\"10\"` produces `w-10 h-10` (40px). Common values: `\"6\"` (24px), `\"8\"` (32px), `\"10\"` (40px), `\"14\"` (56px)."
)
attr(:class, :string,
default: nil,
doc: "Additional CSS classes merged with the size-derived classes."
)
@doc """
Renders a circular skeleton placeholder for avatar images.
The `:size` attribute maps directly to Tailwind's numeric sizing scale:
| size | output classes | px size |
|-------|----------------|---------|
| `"6"` | `w-6 h-6` | 24 px |
| `"8"` | `w-8 h-8` | 32 px |
| `"10"`| `w-10 h-10` | 40 px |
| `"14"`| `w-14 h-14` | 56 px |
## Example
<%!-- Default 40px avatar skeleton --%>
<.skeleton_avatar />
<%!-- 56px skeleton matching a "lg" avatar --%>
<.skeleton_avatar size="14" />
"""
def skeleton_avatar(assigns) do
~H"""
<.skeleton
shape={:circle}
class={cn(["w-#{@size} h-#{@size}", @class])}
/>
"""
end
# ---------------------------------------------------------------------------
# skeleton_card/1 — composite card placeholder
# ---------------------------------------------------------------------------
attr(:class, :string,
default: nil,
doc: "Additional CSS classes applied to the card wrapper `<div>`."
)
@doc """
Renders a composite card-shaped skeleton with a header image area and text
lines below it.
Useful as a drop-in replacement for product cards, blog post cards, or
dashboard stat cards while their content is loading.
## Example
<div class="grid grid-cols-3 gap-4">
<%= for _ <- 1..3 do %>
<.skeleton_card />
<% end %>
</div>
"""
def skeleton_card(assigns) do
~H"""
<div class={cn(["rounded-lg border p-4 space-y-3", @class])}>
<%!-- Tall rectangle simulates a card image or header area --%>
<.skeleton class="h-40 w-full" />
<%!-- Three text lines simulate title + excerpt --%>
<.skeleton_text lines={3} />
</div>
"""
end
# ---------------------------------------------------------------------------
# skeleton_list/1 — list of items placeholder
# ---------------------------------------------------------------------------
attr(:items, :integer,
default: 5,
doc: "Number of list item rows to render."
)
attr(:show_avatar, :boolean,
default: true,
doc: "When `true`, each row has a circular avatar placeholder on the left."
)
attr(:class, :string, default: nil, doc: "Additional CSS classes for the wrapper.")
@doc """
Renders a list of skeleton rows simulating a user list, contact list, or feed.
Each row has an optional avatar circle on the left and two text lines
(title + subtitle) on the right.
## Example
<%= if @loading do %>
<.skeleton_list items={8} />
<% else %>
<%= for user <- @users do %>
<.user_row user={user} />
<% end %>
<% end %>
"""
def skeleton_list(assigns) do
~H"""
<div class={cn(["space-y-3", @class])}>
<%= for _ <- 1..@items do %>
<div class="flex items-center gap-3">
<.skeleton_avatar :if={@show_avatar} size="10" />
<div class="flex-1 space-y-1.5">
<.skeleton class="h-3.5 w-3/4" />
<.skeleton class="h-3 w-1/2" />
</div>
</div>
<% end %>
</div>
"""
end
# ---------------------------------------------------------------------------
# skeleton_form/1 — form skeleton
# ---------------------------------------------------------------------------
attr(:fields, :integer,
default: 4,
doc: "Number of label + input field pairs to render."
)
attr(:show_submit, :boolean,
default: true,
doc: "When `true`, renders a button skeleton at the bottom."
)
attr(:class, :string, default: nil, doc: "Additional CSS classes.")
@doc """
Renders a form-shaped skeleton with label + input pairs.
Useful as a placeholder while a form's initial values are loading (e.g.
an edit form waiting for a record to be fetched).
## Example
<%= if @loading do %>
<.skeleton_form fields={6} />
<% else %>
<.my_form changeset={@changeset} />
<% end %>
"""
def skeleton_form(assigns) do
~H"""
<div class={cn(["space-y-4", @class])}>
<%= for _ <- 1..@fields do %>
<div class="space-y-1.5">
<%!-- Label --%>
<.skeleton class="h-3.5 w-24" />
<%!-- Input --%>
<.skeleton class="h-10 w-full" />
</div>
<% end %>
<%!-- Submit button --%>
<.skeleton :if={@show_submit} class="h-10 w-28 mt-2" />
</div>
"""
end
# ---------------------------------------------------------------------------
# skeleton_table_row/1 — table row placeholder
# ---------------------------------------------------------------------------
attr(:columns, :integer,
default: 4,
doc: "Number of cell placeholders per row."
)
attr(:rows, :integer,
default: 5,
doc: "Number of rows to render."
)
attr(:class, :string, default: nil, doc: "Additional CSS classes for the wrapper.")
@doc """
Renders skeleton rows for a table body.
Use inside a `<tbody>` while table data is loading. The `:columns` attr
controls how many cells to render per row.
## Example
<tbody>
<%= if @loading do %>
<.skeleton_table_row columns={5} rows={8} />
<% else %>
<%= for row <- @rows do %>
<tr>...</tr>
<% end %>
<% end %>
</tbody>
"""
def skeleton_table_row(assigns) do
~H"""
<%= for _ <- 1..@rows do %>
<tr class={@class}>
<%= for _ <- 1..@columns do %>
<td class="p-4">
<.skeleton class="h-4 w-full" />
</td>
<% end %>
</tr>
<% end %>
"""
end
# ---------------------------------------------------------------------------
# skeleton_profile/1 — profile page placeholder
# ---------------------------------------------------------------------------
attr(:class, :string, default: nil, doc: "Additional CSS classes.")
@doc """
Renders a profile-page-shaped skeleton.
Simulates the typical layout of a user profile: large avatar, name, bio,
and a row of stats (posts, followers, following).
## Example
<%= if @loading do %>
<.skeleton_profile />
<% else %>
<.user_profile user={@user} />
<% end %>
"""
def skeleton_profile(assigns) do
~H"""
<div class={cn(["flex flex-col items-center gap-4 py-8", @class])}>
<%!-- Large avatar --%>
<.skeleton_avatar size="20" />
<%!-- Name + handle --%>
<div class="flex flex-col items-center gap-2 w-full max-w-xs">
<.skeleton class="h-5 w-36" />
<.skeleton class="h-3.5 w-24" />
</div>
<%!-- Bio --%>
<div class="w-full max-w-sm space-y-1.5">
<.skeleton class="h-3.5 w-full" />
<.skeleton class="h-3.5 w-5/6" />
<.skeleton class="h-3.5 w-4/6" />
</div>
<%!-- Stats row --%>
<div class="flex gap-8 mt-2">
<%= for _ <- 1..3 do %>
<div class="flex flex-col items-center gap-1">
<.skeleton class="h-5 w-10" />
<.skeleton class="h-3 w-14" />
</div>
<% end %>
</div>
</div>
"""
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
# Rectangle: moderately rounded corners (matches card, button, input shapes)
defp shape_class(:rectangle), do: "rounded-md"
# Circle: fully rounded (for avatar placeholders)
defp shape_class(:circle), do: "rounded-full"
# Build inline style from width/height, avoiding empty style="" attributes.
# Each clause handles a different combination of nil/non-nil values.
defp style_attrs(nil, nil), do: %{}
defp style_attrs(width, nil), do: %{style: "width: #{width};"}
defp style_attrs(nil, height), do: %{style: "height: #{height};"}
defp style_attrs(width, height), do: %{style: "width: #{width}; height: #{height};"}
end