The component is one tag with a sample of the data that is coming, and whatever markup should stand in for the content while it loads.

With an async assign

def mount(%{"id" => id}, _session, socket) do
  {:ok, assign_async(socket, :user, fn -> {:ok, %{user: Accounts.get_user!(id)}} end)}
end
<Skullet.skeleton :let={user} id="profile" async={@user} sample={@sample_user}>
  <Profile.card user={user} />
</Skullet.skeleton>

While @user is loading, Profile.card is rendered from @sample_user and measured. When the assign resolves and LiveView patches the real user in, the skeleton crossfades out. During the dead render the skeleton is the whole page, so the layout never jumps on connect.

A background refetch keeps the content on screen: assign_async/3 leaves the previous result in place while the new one loads, and the skeleton only returns when the assign is reset — pass reset: true (or assign a fresh AsyncResult.loading()).

With plain data

<Skullet.skeleton :let={user} id="profile" data={@user} sample={@sample_user}>
  <Profile.card user={user} />
</Skullet.skeleton>

data that is nil means loading; anything else is loaded. Same component, same skeleton.

Attributes

attributetypedefaultwhat
idstring, required—DOM id for the hook; a generated one would change every render and remount the hook
sampleany, required—stands in for the data to draw the skeleton
async%AsyncResult{}nilfrom assign_async/3
dataanynilplain data; nil is loading
loadingbooleanfalseforces the skeleton, even over data
emptybooleanfalseshows the empty_message slot in place of the content (see empty states)
reveal"smart" | "together" | "sequence" | "instant""smart"how content replaces its skeleton (see reveals)
thememap%{}custom properties for this skeleton alone (see theming)
labelstring"Loading"read to screen readers while loading
rememberanynilopt in to memory under this scope (see memory)

Slots: inner_block (required, receives the sample then the real value), skeleton (your own markup to measure instead of the sample — see per-component skeletons), error (rendered in place of the content when the async assign failed — see errors) and empty_message (rendered in place of the content while empty is true — see empty states).

Empty states

An empty result is neither loading nor an error: the data arrived and there is nothing in it. empty swaps the content pane for the empty_message slot, and the reveal treats that message as content — it measures it and eases to its height, with no skeleton of its own.

<Skullet.skeleton :let={messages} id="inbox" data={@messages} sample={@sample_messages}
  empty={@messages == []}>
  <Inbox.messages messages={messages} />
  <:empty_message>
    <p class="rounded-lg border border-dashed border-base-300 p-6 text-center text-sm">
      No messages yet.
    </p>
  </:empty_message>
</Skullet.skeleton>

It is your markup, styled none of it by the library — the same deal as the error slot. Two rules keep it predictable:

  • Content first. empty only replaces content that arrived. While the data is still missing the skeleton shows as usual (from the sample — the result is not known to be empty yet), and a failed assign still renders the error slot instead.
  • No message, no change. Leave the slot out and the content stays: empty on its own renders what it always did.

With an async assign, emptiness rides on the result:

empty={@messages.ok? and @messages.result == []}

Errors

The error slot is your markup, and the library styles none of it: an alert, an empty state, a retry form, or the same card frame around all three. It replaces the content pane when the assign failed, and the loading label goes with it.

<Skullet.skeleton :let={user} id="profile" async={@user} sample={@sample_user}>
  <Profile.card user={user} />
  <:error>
    <div class="rounded-lg border border-error/40 bg-error/10 p-4">
      <p class="text-sm">Could not load the profile.</p>
      <button phx-click="retry" class="btn btn-xs mt-2">Try again</button>
    </div>
  </:error>
</Skullet.skeleton>

Want the error inside your card — same border, same padding — rather than in place of it? Render that frame in the slot:

<:error>
  <Profile.card user={@sample_user} class="opacity-60">
    <:footer><p class="text-sm">Could not load.</p></:footer>
  </Profile.card>
</:error>

Boxes

For what cannot be measured — a logo, a chart, a canvas — put a box in the sample in place of the thing:

<Skullet.box width={240} height={120} radius="12px" />

It is drawn as a single block, like anything marked skullet-block.

Per-component skeletons

Some things measure badly however you mark them. Give the component a companion skeleton and pass it to the skeleton slot — it is rendered and measured in place of the sample:

<Skullet.skeleton id="chart" async={@chart} sample={@sample}>
  <Chart.chart chart={…} />
  <:skeleton><Chart.skeleton /></:skeleton>
</Skullet.skeleton>

Chart.skeleton/1 is ordinary markup — <Skullet.box> blocks, a placeholder axis, an outline of the chart. It is only shown while loading, and the bars are measured from it rather than from the real chart.

This is also the Suspense shape: an async assign, a measured fallback while it loads, and one error branch. There is no separate component for it:

<Skullet.skeleton :let={quote} id="quote" async={@quote} sample={@sample_quote}>
  <Quote.quote quote={quote} />
  <:skeleton><Quote.quote quote={@sample_quote} /></:skeleton>
  <:error>Could not load the quote.</:error>
</Skullet.skeleton>

Hints

Measuring gets most markup right. Where it does not — a logo, a chart, a canvas — say so in the markup:

<div skullet-block>…</div>          <!-- one block, nothing inside it measured -->
<span skullet-ignore>New</span>     <!-- left out entirely -->

Memory

The first skeleton of a session is drawn from your sample. Every one after that can be drawn from the real data instead, which makes it exact at any viewport size:

<Skullet.skeleton :let={user} id="profile" async={@user} remember={@current_user.id}>
  <Profile.card user={user} />
</Skullet.skeleton>

remember is the scope — usually the user's id — and id completes the key. Once the skeleton has shown real data, the next loading render uses that data in place of the sample.

It is stored server-side (ETS), not in the browser: no shapes in localStorage, nothing left on a shared device, and it works across devices because it is data rather than pixels. It is per node, and it expires after config :skullet, :memory_ttl_ms (30 minutes by default). Clear a person's memory when they sign out:

Skullet.Memory.forget(current_user.id)

Images

An image that is still decoding when the content arrives would pop in. It waits at zero opacity and fades in when it is ready; one that never arrives shows after ten seconds rather than staying hidden. Give images a width and height (or an aspect-ratio) — that is what reserves their space, with or without a skeleton over it.

Before the first paint

The sample is laid out from the server's HTML, but the bars are drawn by JavaScript — so until the bundle loads and the LiveView mounts, the page shows an empty box. One component closes that gap:

<%!-- once per page, at the end of <body>, after the markup it measures --%>
<Skullet.Boot.boot nonce={assigns[:csp_nonce]} />

It inlines a ~9KB script that measures and draws every loading skeleton synchronously, before the browser's first paint, using the same geometry, font metrics and shimmer code as the engine; the engine then takes those bars over.

It is a plain <script>, so a Content Security Policy needs its nonce; the next section is the whole recipe. Without a CSP, leave the attribute off.

Content Security Policy

Skullet needs a nonce in two places, and neither of them is 'unsafe-inline':

  • the boot script, which <Skullet.Boot.boot /> inlines into the page;
  • the theme, which is emitted as a <style> block rather than a style attribute — a nonce rescues <style> elements, and nothing rescues a style attribute.

Everything else is ordinary: the engine and the stylesheet are assets from your own origin, and the bars are positioned through the CSSOM, which no policy blocks.

Make the nonce where the policy is made, keep it for the rest of the request, and hand it to whatever emits markup:

# lib/my_app_web/plugs/csp.ex
defmodule MyAppWeb.CSP do
  @behaviour Plug
  import Plug.Conn

  @impl true
  def init(opts), do: opts

  @impl true
  def call(conn, _opts) do
    nonce = Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false)

    conn
    |> assign(:csp_nonce, nonce)
    # the LiveView's own render can only read it from the session
    |> put_session(:csp_nonce, nonce)
    |> put_resp_header("content-security-policy", policy(nonce))
  end

  defp policy(nonce) do
    "default-src 'self'; " <>
      "script-src 'self' 'nonce-#{nonce}'; " <>
      "style-src 'self' 'nonce-#{nonce}'; " <>
      "img-src 'self' data:; connect-src 'self'; base-uri 'none'"
  end
end
# router.ex, in the :browser pipeline, before any LiveView mounts
plug :fetch_session
# …
plug MyAppWeb.CSP
# the LiveView
def mount(_params, session, socket) do
  {:ok, assign(socket, :csp_nonce, session["csp_nonce"])}
end

Then pass it to the four places that emit markup:

<%!-- layouts/root.html.heex --%>
<Skullet.Boot.boot nonce={@csp_nonce} />

<%!-- any inline script of your own, e.g. a theme bootstrap --%>
<script nonce={@csp_nonce}>…</script>

<%!-- the theme: a provider, a skeleton with its own theme, a box --%>
<Skullet.provider theme={%{radius: "6px"}} nonce={@csp_nonce}>
  <Skullet.skeleton id="chart" sample={@sample} theme={%{speed: "fast"}} nonce={@csp_nonce}>
    <Skullet.box width={240} height={120} nonce={@csp_nonce} />
  </Skullet.skeleton>
</Skullet.provider>

theme and mode become #the-id { --skullet-…: … } in a <style> block that carries the nonce, and a box becomes a class plus its own rule — which is why nothing needs a style attribute. Without a nonce, the same blocks are emitted unsigned and a strict policy drops them, so the theme silently falls back to the defaults.

Two things that surprise people:

  • The nonce has to reach the LiveView, not just the request. The root layout is rendered twice — once for the HTML the request sends, once by the LiveView after it connects — and the second render has only socket assigns. The session is the one place both see, which is why the plug writes it there. Skip that and the attribute is simply dropped on the second render; the script has already run by then, but the markup no longer tells the truth.
  • A style attribute cannot be rescued by a nonce. That is why the theme is a rule and a box is a class: per-instance styling belongs in a <style> block with the nonce on it. A chart of your own with per-bar heights wants the same treatment.

Debugging

Add ?skullet-debug to the URL to outline every skeleton: blue while it loads, then green if the content arrived where the skeleton was, red with the distance in the title if it moved. skullet-debug on any one element does the same for that subtree, and skullet-xray on an ancestor shows the sample through the skeleton.

Plain pages

The engine does not need LiveView. Given the markup the component renders, attach(root) starts the engine on that element and setDebug(on) turns the outline on for the page:

import { attach, setDebug } from "../../deps/skullet/priv/static/skullet.js"

attach(document.querySelector(".skullet"))

attach returns the engine, so a page that swaps content itself drives it the way the LiveView hook does: beforeUpdate(), then update() after the new markup is in, and destroy() when the element goes. It takes the reveal as an option — attach(root, { reveal: spec }) — which wins over the element's reveal attribute; see reveals.

The markup is the component's: a .skullet root with data-skullet-state (loading or loaded), a [data-skullet-overlay] for the bars, and a [data-skullet-content] around the content. Loading it without a bundler is in installation.