Loading

Animated indicator for in-flight tasks, with ring, dot, and bar variants.

Basic

.heex
<.loading />

Variants

Each variant draws a different shape and uses its own keyframe animation. Rings work as general spinners, dots fit casual or passive contexts, bars suit processing or media.

.heex
<.loading variant="ring" />
<.loading variant="ring-bg" />
<.loading variant="dots-bounce" />
<.loading variant="dots-fade" />
<.loading variant="dots-scale" />
<.loading variant="bars-fade" />
<.loading variant="bars-scale" />
<.loading variant="bars-scale-fade" />

Sizes

The SVG scales proportionally with any Tailwind sizing utility on class.

.heex
<.loading class="size-3" />
<.loading class="size-5" />
<.loading class="size-8" />
<.loading class="size-12" />

Colors

The indicator paints with currentColor, so any text-* utility sets the visible color.

.heex
<.loading class="size-6 text-foreground" />
<.loading class="size-6 text-primary" />
<.loading class="size-6 text-success" />
<.loading class="size-6 text-warning" />
<.loading class="size-6 text-danger" />

Animation speed

duration sets one full animation cycle in milliseconds. Lower feels urgent, higher feels ambient.

300ms
600ms (default)
1200ms
.heex
<.loading class="size-8" duration={300} />
<.loading class="size-8" duration={600} />
<.loading class="size-8" duration={1200} />

Inside a button

Pair with <.button> and a disabled flag to reflect in-flight submissions and prevent duplicate sends.

.heex
<.button variant="solid" color="primary" disabled>
  <.loading class="size-4 text-white" /> Saving
</.button>

<.button variant="outline" disabled>
  <.loading variant="dots-fade" class="size-4" /> Loading
</.button>

Click-driven transitions

Toggle data-loading with JS.toggle_attribute/1, then drive the swap entirely in CSS with Tailwind's in-data-loading: variant. Click each button to play the animation.

.heex
<.button
  variant="solid"
  color="primary"
  class="overflow-hidden relative"
  phx-click={JS.toggle_attribute({"data-loading", "true"})}
>
  <span class="block transition-transform duration-300 in-data-loading:translate-y-[200%]">
    Save changes
  </span>
  <.loading class="absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 transition-opacity duration-300 in-data-loading:opacity-100" />
</.button>

<.button
  variant="outline"
  class="relative"
  phx-click={JS.toggle_attribute({"data-loading", "true"})}
>
  <span class="transition-opacity duration-200 in-data-loading:opacity-0">
    Submit
  </span>
  <.loading
    variant="dots-fade"
    class="absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-300 in-data-loading:opacity-100"
  />
</.button>

<.button
  variant="soft"
  color="info"
  class="relative"
  phx-click={JS.toggle_attribute({"data-loading", "true"})}
>
  <span class="transition duration-300 in-data-loading:scale-0">
    Subscribe
  </span>
  <.loading
    variant="ring-bg"
    class="absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 scale-0 transition duration-300 in-data-loading:scale-100"
  />
</.button>

Inline status

Syncing latest changes
Processing 24 records
Connecting to server
.heex
<div class="flex items-center gap-2 text-sm text-foreground-soft">
  <.loading variant="dots-fade" class="size-4" /> Syncing latest changes
</div>

Loading overlay

While a server-side handle_event is in flight, Phoenix adds a phx-click-loading class to the source element. Tailwind's group-has-* variant on the wrapper picks that up to fade in an overlay, with no client state and no JS. Click Refresh now to see it live.

Refresh report data

Pulls the latest figures from the warehouse.

Records
12,438
Last sync
2 minutes ago
Refreshing
.heex
<div class="group relative ...">
  <%!-- card content --%>
  <.button phx-click="refresh_report" variant="solid" color="primary">
    Refresh now
  </.button>

  <div class="pointer-events-none absolute inset-0 flex items-center justify-center gap-3 bg-background/80 opacity-0 backdrop-blur-sm transition-opacity duration-200 group-has-[.phx-click-loading]:pointer-events-auto group-has-[.phx-click-loading]:opacity-100">
    <.loading variant="ring-bg" class="size-6 text-primary" />
    <span class="text-sm font-medium text-foreground">Refreshing</span>
  </div>
</div>
.ex
def handle_event("refresh_report", _params, socket) do
  # Refresh the report; the overlay stays visible until this returns
  {:noreply, socket}
end

Chat typing indicator

dots-bounce reads as conversational. Pair it with a label inside chat or assistant surfaces.

Assistant is typing
.heex
<div class="inline-flex items-center gap-2 rounded-full bg-background-soft px-3 py-1.5 text-sm text-foreground-soft">
  <.loading variant="dots-bounce" class="size-4 text-primary" /> Assistant is typing
</div>