component

Loading

Wraps any content and turns it into its own shimmering skeleton while loading is true. The placeholder follows the shape of whatever children you pass — headings, paragraphs, buttons, avatars, cards — so the same markup serves as both the real content and its skeleton.

  • Text becomes one bar per line, as wide as that line’s text — wrapped paragraphs, inline markup (<p>Hello <strong>world</strong></p>) and bare text beside a nested block included. The bars are drawn as text decorations, so their ends are square. The skeleton pulses its opacity instead of sweeping a gradient — a gentle breathe (to 60% over a 2s cycle). Text placed directly in Loading, outside any element, is hidden without a bar.
  • Images and video keep their box and shimmer inside it.
  • Empty decorative elements (spacers, separators) are left alone.
  • Bars keep each component’s own colour — a success badge shimmers green, a primary button blue — while plain text follows the region’s colorPalette (neutral by default). The tint is translucent, so it reads on any surface in light and dark mode.

Rendered without children, <Loading /> is a standalone spinner (see Standalone spinner).

Which variant, and when

Two loading semantics, picked with the variant prop:

  • skeleton (default) — you don’t have the content yet. Every descendant is replaced with shimmer so the user sees the shape of what’s loading. Reach for this on first render, after a navigation, or when the content structure is known but the data isn’t.
  • busy — you already have the content, but an action is in flight (saving a form, refreshing a feed, running a search, a button’s request). The content stays exactly as it is — nothing pulses, so nothing swings its contrast — and an indicator says it’s working. Users keep their context, they just can’t interact until it resolves.

Rule of thumb: if the user has never seen this content before, use skeleton. If they’re waiting for the same content to update, use busy.

Picking the wrapper element

Loading renders its root with display: contents, so the wrapper disappears from layout and the children sit directly in their natural flow (the bar and overlay indicators are the exception: they stack over the content, so the root becomes a one-cell grid). Wrap at the granularity you want the loading state to cover:

  • Wrapping a single card gives you a card-level skeleton.
  • Wrapping a list wrapper gives you a list-level skeleton — each row already composes into its own shape.
  • Wrapping a form lets you show busy across the whole form including its submit button.
  • Wrapping a single button with indicator="inline" gives you a loading button (see Buttons).

Per-child overrides

For the rare mixed cases, drop data-loading-as on any descendant to change the treatment locally:

  • data-loading-as="skeleton" — force skeleton on this subtree even if the ancestor is busy (e.g. a stat number that’s still being computed while the rest of the form blurs).
  • data-loading-as="busy" — keep this subtree as it is inside a skeleton (e.g. a persistent “Saving…” button that stays readable); still inert.
  • data-loading-as="none" — opt a subtree out entirely (e.g. a progress bar or spinner that drives the loading state itself). An opted-out element stays fully visible and still. Text beside it in the same parent still gets a bar, down to two levels below the content’s top-level elements; deeper than that, the neighbouring text is hidden without one.

Works with any component

Loading never needs to know which components it wraps. A component tells it how to draw itself through the shape contract: its connect bakes data-loading-shape onto its root.

data-loading-shapeTreatment
blockOne shimmer block in the component’s own shape (radius included); its inner text and icons are hidden
circleA block with a fully rounded radius (avatars, round stamps)
skipLeft out of the shimmer entirely (live regions, decorative indicators)
(absent)Walked like plain markup: text becomes line bars, media shimmers in place

A new component opts in with one attribute in its own connect — the loading recipe never lists components. Components that don’t carry the attribute yet fall back to built-in detection (buttons, badges, stamps, checkboxes, radios, alerts and wrapped form controls become blocks).

Portaled layers (an open popover or tooltip) render outside the region, so they are not shimmered. The inert content can’t open new ones.

Accessibility

  • The root gets aria-busy="true" while loading.
  • The content sits in an inert wrapper, so keyboard focus can’t land on a placeholder and stale or fake content leaves the accessibility tree.
  • A visually hidden polite status region outside that wrapper announces label (default "Loading") when loading starts. Pass a translated label for other languages.
  • The shimmer animations respect prefers-reduced-motion and collapse to a static state.

Default

Toggle the loading state — the same markup serves as both the real content and its skeleton. Every line of text becomes a bar exactly as long as its words, inline markup and links included, with nothing extra in the markup. Plain text has no busy treatment — busy keeps text readable and as it is — so this demo only shows skeleton; the demos below pair it with components.

Loading

Planning the spring trip

The team meets in Lisbon for three days in April. Flights are booked for Tuesday morning, and the venue near the river holds up to forty people with room for breakout sessions.

Send dietary needs to Marta by Friday, and add your talk to the shared agenda so we can plan the schedule.

---
// GENERATED from packages/demos/loading/default.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Checkbox from "@pindoba/astro-checkbox";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const loading = true;
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/default"
>
  <Checkbox
    id="astro-demo-loading-default-toggle"
    appearance="switch"
    checked
    data-ir="1"
  >
    Loading
  </Checkbox>

  <Loading loading={loading} data-ir="2">
    <article>
      <h3 class={css({ fontSize: "xl", fontWeight: "bold", mb: "sm" })}>
        Planning the spring trip
      </h3>
      <p class={css({ mb: "sm" })}>
        The team meets in Lisbon for three days in April. Flights are booked for
        Tuesday morning, and the venue near the river holds up to forty people
        with room for breakout sessions.
      </p>
      <p>
        Send dietary needs to <strong>Marta</strong> by Friday, and add your
        talk to the <a href="#agenda">shared agenda</a> so we can plan the
        schedule.
      </p>
    </article>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/default.ts";

  document
    .querySelectorAll('[data-ir-root="loading/default"]')
    .forEach((root) => hydrate([root]));
</script>

Card

A profile card composed of Card, Badge and Button. In skeleton everything inside the card becomes a placeholder without extra markup; switch to busy and the card stays as it is under the overlay, which says it’s working.

Loading

Analytical Engine

Ada Lovelace · 1843

ResearchPublished

The Analytical Engine has no pretensions whatever to originate anything. It can do whatever we know how to order it to perform.

---
// GENERATED from packages/demos/loading/card.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Card from "@pindoba/astro-card";
import Button from "@pindoba/astro-button";
import Badge from "@pindoba/astro-badge";
import Checkbox from "@pindoba/astro-checkbox";
import SelectNative from "@pindoba/astro-select-native";
import { stack, hstack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const variants = [
  { id: "skeleton", label: "Skeleton", value: "skeleton" },
  { id: "busy", label: "Busy", value: "busy" },
];

const loading = true;
const variant = "skeleton" as "skeleton" | "busy";
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/card"
>
  <div class={hstack({ gap: "md", alignItems: "center", flexWrap: "wrap" })}>
    <Checkbox
      id="astro-demo-loading-card-toggle"
      appearance="switch"
      checked
      data-ir="1"
    >
      Loading
    </Checkbox>
    <label class={hstack({ gap: "xs", alignItems: "center" })}>
      <span>Variant</span>
      <SelectNative
        items={variants}
        value={"skeleton" as "skeleton" | "busy"}
        autocomplete="off"
        data-ir="2"
      />
    </label>
  </div>

  <Loading loading={loading} variant={variant} data-ir="3">
    <Card
      header={{
        heading: "Analytical Engine",
        subheading: "Ada Lovelace · 1843",
      }}
    >
      <div class={stack({ gap: "md", direction: "column" })}>
        <div class={hstack({ gap: "xs", flexWrap: "wrap" })}>
          <Badge tone="primary">Research</Badge>
          <Badge tone="success">Published</Badge>
        </div>
        <p class={css({ margin: "0" })}>
          The Analytical Engine has no pretensions whatever to originate
          anything. It can do whatever we know how to order it to perform.
        </p>
        <div class={hstack({ gap: "sm" })}>
          <Button emphasis="primary">Read more</Button>
          <Button emphasis="ghost">Share</Button>
        </div>
      </div>
    </Card>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/card.ts";

  document
    .querySelectorAll('[data-ir-root="loading/card"]')
    .forEach((root) => hydrate([root]));
</script>

Loading over messaging components like Banner and Alert. In skeleton each alert collapses into one block and its icon is hidden; in busy the alerts stay exactly as they are.

Loading

Workspace usage

You have used 62% of your monthly quota.

Your profile changes were saved successfully and published to the team.
Billing cycle ends in 3 days. Review your plan before renewal.
---
// GENERATED from packages/demos/loading/banner.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Banner from "@pindoba/astro-banner";
import Alert from "@pindoba/astro-alert";
import Checkbox from "@pindoba/astro-checkbox";
import SelectNative from "@pindoba/astro-select-native";
import { stack, hstack } from "@pindoba/styled-system/patterns";

const variants = [
  { id: "skeleton", label: "Skeleton", value: "skeleton" },
  { id: "busy", label: "Busy", value: "busy" },
];

const loading = true;
const variant = "skeleton" as "skeleton" | "busy";
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/banner"
>
  <div class={hstack({ gap: "md", alignItems: "center", flexWrap: "wrap" })}>
    <Checkbox
      id="astro-demo-loading-banner-toggle"
      appearance="switch"
      checked
      data-ir="1"
    >
      Loading
    </Checkbox>
    <label class={hstack({ gap: "xs", alignItems: "center" })}>
      <span>Variant</span>
      <SelectNative
        items={variants}
        value={"skeleton" as "skeleton" | "busy"}
        autocomplete="off"
        data-ir="2"
      />
    </label>
  </div>

  <Loading loading={loading} variant={variant} data-ir="3">
    <div class={stack({ gap: "md", direction: "column" })}>
      <Banner
        heading="Workspace usage"
        subheading="You have used 62% of your monthly quota."
      />
      <Alert tone="success">
        Your profile changes were saved successfully and published to the team.
      </Alert>
      <Alert tone="warning">
        Billing cycle ends in 3 days. Review your plan before renewal.
      </Alert>
    </div>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/banner.ts";

  document
    .querySelectorAll('[data-ir-root="loading/banner"]')
    .forEach((root) => hydrate([root]));
</script>

Form

A form while it saves — the case busy is for. The fields, select, checkbox and buttons stay readable and inert under the overlay; switch to skeleton for a form whose data is still loading.

Loading
---
// GENERATED from packages/demos/loading/form.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Input from "@pindoba/astro-input";
import SelectNative from "@pindoba/astro-select-native";
import Button from "@pindoba/astro-button";
import Checkbox from "@pindoba/astro-checkbox";
import { stack, hstack } from "@pindoba/styled-system/patterns";

const variants = [
  { id: "skeleton", label: "Skeleton", value: "skeleton" },
  { id: "busy", label: "Busy", value: "busy" },
];
const countries = [
  { label: "Brazil", value: "br" },
  { label: "Portugal", value: "pt" },
  { label: "United States", value: "us" },
];

const loading = true;
const variant = "busy" as "skeleton" | "busy";
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/form"
>
  <div class={hstack({ gap: "md", alignItems: "center", flexWrap: "wrap" })}>
    <Checkbox
      id="astro-demo-loading-form-toggle"
      appearance="switch"
      checked
      data-ir="1"
    >
      Loading
    </Checkbox>
    <label class={hstack({ gap: "xs", alignItems: "center" })}>
      <span>Variant</span>
      <SelectNative
        items={variants}
        value={"busy" as "skeleton" | "busy"}
        autocomplete="off"
        data-ir="2"
      />
    </label>
  </div>

  <Loading loading={loading} variant={variant} data-ir="3">
    <form class={stack({ gap: "md", direction: "column" })}>
      <Input placeholder="Full name" />
      <Input placeholder="Email" type="email" />
      <SelectNative items={countries} />
      <Checkbox id="astro-demo-loading-form-updates">
        Email me product updates
      </Checkbox>
      <div class={hstack({ gap: "sm" })}>
        <Button emphasis="primary" type="submit">
          Create account
        </Button>
        <Button emphasis="ghost" type="button">
          Cancel
        </Button>
      </div>
    </form>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/form.ts";

  document
    .querySelectorAll('[data-ir-root="loading/form"]')
    .forEach((root) => hydrate([root]));
</script>

Feed

A feed layout with Stamp avatars, Badge tags and Button actions. Each post becomes its own composed skeleton.

Loading
  • Grace Hopper@amazing_grace· 2hMentorPioneer
    Rear Admiral · US Navy

    The most damaging phrase in the language is 'we've always done it this way'. Question everything — especially the defaults.

  • Alan Turing@enigma· 5hAuthor
    Mathematician · Bletchley Park

    Sometimes it is the people no one can imagine anything of who do the things no one can imagine.

  • Ada Lovelace@countess_of_code· 1dFounder
    Analyst · Analytical Engine

    The Analytical Engine weaves algebraical patterns, just as the Jacquard loom weaves flowers and leaves.

---
// GENERATED from packages/demos/loading/feed.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Checkbox from "@pindoba/astro-checkbox";
import SelectNative from "@pindoba/astro-select-native";
import { User, MessageCircle, Repeat2, Heart, Share } from "@lucide/astro";
import { stack, hstack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const posts = [
  {
    name: "Grace Hopper",
    handle: "@amazing_grace",
    role: "Rear Admiral · US Navy",
    tags: ["Mentor", "Pioneer"],
    time: "2h",
    text: "The most damaging phrase in the language is 'we've always done it this way'. Question everything — especially the defaults.",
    likes: "1.2k",
    replies: "84",
    reposts: "312",
  },
  {
    name: "Alan Turing",
    handle: "@enigma",
    role: "Mathematician · Bletchley Park",
    tags: ["Author"],
    time: "5h",
    text: "Sometimes it is the people no one can imagine anything of who do the things no one can imagine.",
    likes: "892",
    replies: "47",
    reposts: "156",
  },
  {
    name: "Ada Lovelace",
    handle: "@countess_of_code",
    role: "Analyst · Analytical Engine",
    tags: ["Founder"],
    time: "1d",
    text: "The Analytical Engine weaves algebraical patterns, just as the Jacquard loom weaves flowers and leaves.",
    likes: "2.4k",
    replies: "128",
    reposts: "498",
  },
];
const variants = [
  { id: "skeleton", label: "Skeleton", value: "skeleton" },
  { id: "busy", label: "Busy", value: "busy" },
];

const loading = true;
const variant = "skeleton" as "skeleton" | "busy";
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "xl" })}
  data-ir-root="loading/feed"
>
  <div class={hstack({ gap: "md", alignItems: "center", flexWrap: "wrap" })}>
    <Checkbox
      id="astro-demo-loading-feed-toggle"
      appearance="switch"
      checked
      data-ir="1"
    >
      Loading
    </Checkbox>
    <label class={hstack({ gap: "xs", alignItems: "center" })}>
      <span>Variant</span>
      <SelectNative
        items={variants}
        value={"skeleton" as "skeleton" | "busy"}
        autocomplete="off"
        data-ir="2"
      />
    </label>
  </div>

  <Loading loading={loading} variant={variant} data-ir="3">
    <ul
      class={stack({
        gap: "lg",
        direction: "column",
        listStyle: "none",
        padding: "0",
        margin: "0",
      })}
    >
      <!--ir:4-->
      {posts.map((post) => (
        <li
          class={hstack({ gap: "md", alignItems: "flex-start" })}
          data-ir-key={post.handle}
        >
          <Stamp shape="circle">
            <User />
          </Stamp>
          <div class={stack({ gap: "sm", direction: "column", flex: "1" })}>
            <div
              class={hstack({
                gap: "sm",
                alignItems: "center",
                flexWrap: "wrap",
              })}
            >
              <strong>
                <!--ir:5-->
                {post.name}
                <!--/ir-->
              </strong>
              <span
                class={css({ color: "neutral.text.muted", fontSize: "sm" })}
              >
                <!--ir:6-->
                {post.handle}
                <!--/ir-->
              </span>
              <span
                class={css({ color: "neutral.text.muted", fontSize: "sm" })}
              >
                · <!--ir:7-->
                {post.time}
                <!--/ir-->
              </span>
              <!--ir:8-->
              {post.tags.map((tag) => (
                <Badge tone="primary" size="sm" data-ir-key={tag}>
                  <Fragment set:html="<!--ir:9-->" />
                  {tag}
                  <Fragment set:html="<!--/ir-->" />
                </Badge>
              ))}
              <!--/ir-->
            </div>
            <span class={css({ color: "neutral.text.muted", fontSize: "sm" })}>
              <!--ir:10-->
              {post.role}
              <!--/ir-->
            </span>
            <p class={css({ margin: "0", lineHeight: "1.5" })}>
              <span>
                <!--ir:11-->
                {post.text}
                <!--/ir-->
              </span>
            </p>
            <div
              class={hstack({
                gap: "lg",
                color: "neutral.text.muted",
                fontSize: "sm",
              })}
            >
              <Button emphasis="ghost" size="sm">
                <MessageCircle />
                <span>
                  <!--ir:12-->
                  {post.replies}
                  <!--/ir-->
                </span>
              </Button>
              <Button emphasis="ghost" size="sm">
                <Repeat2 />
                <span>
                  <!--ir:13-->
                  {post.reposts}
                  <!--/ir-->
                </span>
              </Button>
              <Button emphasis="ghost" size="sm">
                <Heart />
                <span>
                  <!--ir:14-->
                  {post.likes}
                  <!--/ir-->
                </span>
              </Button>
              <Button aria-label="Share" emphasis="ghost" size="sm">
                <Share />
              </Button>
            </div>
          </div>
        </li>
      ))}
      <!--/ir-->
    </ul>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/feed.ts";

  document
    .querySelectorAll('[data-ir-root="loading/feed"]')
    .forEach((root) => hydrate([root]));
</script>

Keep parts live

Mark a subtree with data-loading-as="none" to keep it out of the treatment — here the Progress bar and the Cancel Button that drive the sync stay live and clickable while the repository list loads.

Loading

Syncing repositories

62%
  • web-appDashboard · 1,284 files
    Synced
  • mobileMobile apps · 932 files
    Syncing
  • infrastructureTerraform · 210 files
    Queued
---
// GENERATED from packages/demos/loading/live.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Progress from "@pindoba/astro-progress";
import Checkbox from "@pindoba/astro-checkbox";
import SelectNative from "@pindoba/astro-select-native";
import { FolderGit2 } from "@lucide/astro";
import { stack, hstack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const repos = [
  {
    name: "web-app",
    detail: "Dashboard · 1,284 files",
    status: "Synced",
    tone: "success",
  },
  {
    name: "mobile",
    detail: "Mobile apps · 932 files",
    status: "Syncing",
    tone: "primary",
  },
  {
    name: "infrastructure",
    detail: "Terraform · 210 files",
    status: "Queued",
    tone: "neutral",
  },
] as const;
const variants = [
  { id: "skeleton", label: "Skeleton", value: "skeleton" },
  { id: "busy", label: "Busy", value: "busy" },
];

const loading = true;
const variant = "busy" as "skeleton" | "busy";
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/live"
>
  <div class={hstack({ gap: "md", alignItems: "center", flexWrap: "wrap" })}>
    <Checkbox
      id="astro-demo-loading-live-toggle"
      appearance="switch"
      checked
      data-ir="1"
    >
      Loading
    </Checkbox>
    <label class={hstack({ gap: "xs", alignItems: "center" })}>
      <span>Variant</span>
      <SelectNative
        items={variants}
        value={"busy" as "skeleton" | "busy"}
        autocomplete="off"
        data-ir="2"
      />
    </label>
  </div>

  <Loading loading={loading} variant={variant} data-ir="3">
    <section class={stack({ gap: "md", direction: "column" })}>
      <div
        class={hstack({
          gap: "md",
          alignItems: "center",
          justifyContent: "space-between",
        })}
      >
        <h3 class={css({ fontSize: "lg", fontWeight: "bold" })}>
          Syncing repositories
        </h3>
        <Button size="sm" emphasis="ghost" data-loading-as="none">
          Cancel
        </Button>
      </div>
      <Progress
        value={62}
        showLabel
        label="Sync progress"
        data-loading-as="none"
      />
      <ul
        class={stack({
          gap: "md",
          direction: "column",
          listStyle: "none",
          padding: "0",
          margin: "0",
        })}
      >
        <!--ir:4-->
        {repos.map((repo) => (
          <li
            class={hstack({ gap: "md", alignItems: "center" })}
            data-ir-key={repo.name}
          >
            <Stamp shape="circle">
              <FolderGit2 />
            </Stamp>
            <div class={stack({ gap: "2xs", direction: "column", flex: "1" })}>
              <strong>
                <!--ir:5-->
                {repo.name}
                <!--/ir-->
              </strong>
              <span
                class={css({ color: "neutral.text.muted", fontSize: "sm" })}
              >
                <!--ir:6-->
                {repo.detail}
                <!--/ir-->
              </span>
            </div>
            <Badge tone={repo.tone} size="sm" data-ir="7">
              <Fragment set:html="<!--ir:8-->" />
              {repo.status}
              <Fragment set:html="<!--/ir-->" />
            </Badge>
          </li>
        ))}
        <!--/ir-->
      </ul>
    </section>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/live.ts";

  document
    .querySelectorAll('[data-ir-root="loading/live"]')
    .forEach((root) => hydrate([root]));
</script>

Indicators

Busy content doesn’t move, so the indicator alone says the region is working — one small moving thing in a calm region. Pick it with indicator:

indicatorForLooks like
corner / truesmall blocks, rowsa spinner stamp on the top-end corner
barlists, tables, feedsa thin progress line along the top edge
overlaycards, panels, whole formsa light tint with a card: spinner, the label as a message, the percentage — the default for busy
inlinebuttons and single controlsthe label hides, a spinner is drawn inside the control in its own colour
false—none (a busy region without one looks idle)

Every indicator stays mounted, hidden while idle, so toggling loading never remounts the children: a focused field or in-flight child state survives.

Buttons

The main place a loading state shows up. Wrap the button with indicator="inline": it keeps its exact width and its fill — it’s working, not disabled — and its label is swapped for a spinner drawn inside it, so the spinner takes the button’s own text colour (white on primary, ink on ghost). The label still names the button for assistive technology, and the label prop is announced while it’s busy.

<Loading loading={saving} variant="busy" indicator="inline" label="Saving">
  <Button emphasis="primary" onclick={save}>Save changes</Button>
</Loading>

With progress (0–100), the spinner becomes a ring that fills to the value with the percentage beside it.

Each button keeps its width and colour.
Known progress: the ring fills and the percentage replaces the label.
---
// GENERATED from packages/demos/loading/button.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Button from "@pindoba/astro-button";
import { stack, hstack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const caption = css({ color: "neutral.text.muted", fontSize: "sm" });

const saving = false;
const publishing = false;
const retrying = false;
const quick = false;
const uploading = false;
const uploaded = undefined as number | undefined;
---

<div
  class={stack({ gap: "lg", direction: "column" })}
  data-ir-root="loading/button"
>
  <div class={stack({ gap: "xs", direction: "column" })}>
    <span class={caption}>Each button keeps its width and colour.</span>
    <div class={hstack({ gap: "sm", flexWrap: "wrap" })}>
      <Loading
        loading={saving}
        variant="busy"
        indicator="inline"
        label="Saving"
        data-ir="1"
      >
        <Button emphasis="primary" data-ir="2">
          Save changes
        </Button>
      </Loading>
      <Loading
        loading={publishing}
        variant="busy"
        indicator="inline"
        label="Publishing"
        data-ir="3"
      >
        <Button emphasis="secondary" data-ir="4">
          Publish
        </Button>
      </Loading>
      <Loading
        loading={retrying}
        variant="busy"
        indicator="inline"
        label="Retrying"
        data-ir="5"
      >
        <Button emphasis="ghost" data-ir="6">
          Retry
        </Button>
      </Loading>
      <Loading
        loading={quick}
        variant="busy"
        indicator="inline"
        label="Copying"
        data-ir="7"
      >
        <Button emphasis="secondary" data-ir="8">
          Copy link (fast)
        </Button>
      </Loading>
    </div>
  </div>

  <div class={stack({ gap: "xs", direction: "column" })}>
    <span class={caption}>
      Known progress: the ring fills and the percentage replaces the label.
    </span>
    <div class={hstack({ gap: "sm", flexWrap: "wrap", alignItems: "center" })}>
      <Loading
        loading={uploading}
        variant="busy"
        indicator="inline"
        progress={uploaded}
        label="Uploading"
        data-ir="9"
      >
        <Button emphasis="primary" size="lg" data-ir="10">
          Upload report
        </Button>
      </Loading>
      <Loading
        loading={uploading}
        variant="busy"
        indicator="inline"
        progress={uploaded}
        label="Uploading"
        data-ir="11"
      >
        <Button emphasis="secondary" size="sm">
          Attach
        </Button>
      </Loading>
    </div>
  </div>
</div>

<script>
  import { hydrate } from "./_client/button.ts";

  document
    .querySelectorAll('[data-ir-root="loading/button"]')
    .forEach((root) => hydrate([root]));
</script>

Overlay

For a card, a panel or a whole form: a light tint over the content and a card with the spinner, the label as a visible message and — with progress — the percentage. Set --pindoba-loading-radius on the region to match the content’s corner radius (default md).

Over a region taller than the screen, the card stays on screen: it keeps to the middle of the viewport while the region fills it, and to the screen’s edge while only part of the region shows. Inside a scrolling container, that container is the screen.

Profile

---
// GENERATED from packages/demos/loading/overlay.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Input from "@pindoba/astro-input";
import Button from "@pindoba/astro-button";
import Checkbox from "@pindoba/astro-checkbox";
import { stack, hstack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const saving = false;
const progress = undefined as number | undefined;
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/overlay"
>
  <Checkbox
    id="astro-demo-loading-overlay-progress"
    appearance="switch"
    data-ir="1"
  >
    Report progress
  </Checkbox>

  <Loading
    loading={saving}
    variant="busy"
    indicator="overlay"
    label="Saving your profile…"
    progress={progress}
    data-ir="2"
  >
    <form
      class={stack({
        gap: "md",
        direction: "column",
        p: "lg",
        borderWidth: "1px",
        borderStyle: "solid",
        borderColor: "neutral.border.muted",
        borderRadius: "md",
      })}
      data-ir="3"
    >
      <h3 class={css({ fontSize: "lg", fontWeight: "bold", margin: "0" })}>
        Profile
      </h3>
      <Input placeholder="Display name" value="Margaret Hamilton" />
      <Input placeholder="Email" type="email" value="margaret@apollo.dev" />
      <div class={hstack({ gap: "sm" })}>
        <Button emphasis="primary" type="submit">
          Save profile
        </Button>
        <Button emphasis="ghost" type="button">
          Cancel
        </Button>
      </div>
    </form>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/overlay.ts";

  document
    .querySelectorAll('[data-ir-root="loading/overlay"]')
    .forEach((root) => hydrate([root]));
</script>

Bar

For lists, tables and feeds being refreshed: a thin line along the top edge that never covers a row.

  • #4182mainPassed
  • #4181feat/uploadFailed
  • #4180fix/focusPassed
  • #4179mainQueued
---
// GENERATED from packages/demos/loading/bar.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Button from "@pindoba/astro-button";
import Badge from "@pindoba/astro-badge";
import { stack, hstack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const builds = [
  { id: "#4182", branch: "main", status: "Passed", tone: "success" },
  { id: "#4181", branch: "feat/upload", status: "Failed", tone: "danger" },
  { id: "#4180", branch: "fix/focus", status: "Passed", tone: "success" },
  { id: "#4179", branch: "main", status: "Queued", tone: "neutral" },
] as const;

const refreshing = false;
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/bar"
>
  <div class={hstack({ gap: "md", alignItems: "center" })}>
    <Button emphasis="secondary" size="sm" data-ir="1">
      Refresh
    </Button>
  </div>

  <Loading
    loading={refreshing}
    variant="busy"
    indicator="bar"
    label="Refreshing builds"
    data-ir="2"
  >
    <ul
      class={stack({
        gap: "0",
        direction: "column",
        listStyle: "none",
        padding: "0",
        margin: "0",
        borderWidth: "1px",
        borderStyle: "solid",
        borderColor: "neutral.border.muted",
        borderRadius: "md",
      })}
    >
      <!--ir:3-->
      {builds.map((build) => (
        <li
          class={hstack({
            gap: "md",
            justifyContent: "space-between",
            px: "md",
            py: "sm",
            "&:not(:last-child)": {
              borderBottomWidth: "1px",
              borderBottomStyle: "solid",
              borderBottomColor: "neutral.border.muted",
            },
          })}
          data-ir-key={build.id}
        >
          <span class={hstack({ gap: "sm" })}>
            <strong>
              <!--ir:4-->
              {build.id}
              <!--/ir-->
            </strong>
            <span class={css({ color: "neutral.text.muted" })}>
              <!--ir:5-->
              {build.branch}
              <!--/ir-->
            </span>
          </span>
          <Badge tone={build.tone} size="sm" data-ir="6">
            <Fragment set:html="<!--ir:7-->" />
            {build.status}
            <Fragment set:html="<!--/ir-->" />
          </Badge>
        </li>
      ))}
      <!--/ir-->
    </ul>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/bar.ts";

  document
    .querySelectorAll('[data-ir-root="loading/bar"]')
    .forEach((root) => hydrate([root]));
</script>

Corner

For small blocks and rows, where a card over the content would cover it: a spinner stamp on the top-end corner, rendered via Attachment.

Loading

Saving changes…

A short status region where the shimmer alone could be easy to miss. The corner spinner makes the busy state unmistakable.

---
// GENERATED from packages/demos/loading/indicator.demo.tsx — edit the source, not this file.
import Loading from "@pindoba/astro-loading";
import Checkbox from "@pindoba/astro-checkbox";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const loading = true;
---

<div
  class={stack({ gap: "md", direction: "column", maxWidth: "lg" })}
  data-ir-root="loading/indicator"
>
  <Checkbox
    id="astro-demo-loading-indicator-toggle"
    appearance="switch"
    checked
    data-ir="1"
  >
    Loading
  </Checkbox>

  <Loading loading={loading} variant="busy" indicator data-ir="2">
    <article
      class={css({
        p: "md",
        borderWidth: "1px",
        borderStyle: "solid",
        borderColor: "neutral.border.muted",
        borderRadius: "md",
      })}
    >
      <h3 class={css({ fontSize: "lg", fontWeight: "bold", mb: "xs" })}>
        Saving changes…
      </h3>
      <p>
        <span>
          A short status region where the shimmer alone could be easy to miss.
          The corner spinner makes the busy state unmistakable.
        </span>
      </p>
    </article>
  </Loading>
</div>

<script>
  import { hydrate } from "./_client/indicator.ts";

  document
    .querySelectorAll('[data-ir-root="loading/indicator"]')
    .forEach((root) => hydrate([root]));
</script>

Progress

When you know how far along the work is (uploads, imports), pass progress (0–100). The indicator’s spinner becomes a ring — or, for bar, a line — that fills to it; inline and overlay show the percentage too. Leave it unset while the progress is unknown. Screen readers hear the label with the progress only as it passes each quarter (0, 25, 50, 75, 100%), never on every update.

Timing

A loading state that flashes for a fraction of a second is worse than none, and one that blinks off and on again is worse still. So busy waits 300ms (delay) before showing anything — most saves finish sooner and never show it — and, once shown, stays at least 500ms (minDuration). The content turns inert the moment loading is set, so a second click can’t land while the delay runs. skeleton shows at once (delay and minDuration default to 0): it stands in for content that isn’t there.

<Loading loading={pending} variant="busy" delay={0}>…</Loading>

The skeleton’s pulse cycle is set with speed: slow (3s), normal (2s, the default) or fast (1.5s). It dims to 60%; set --pindoba-loading-floor on the region (e.g. 0.8) for a shallower pulse. With reduced motion, nothing pulses or spins.

Standalone spinner

Without children, <Loading /> renders a self-contained inline spinner with the same polite status announcement — the generic “something is loading” element for a list’s loading row, a toolbar or a status line. Its presence is the loading state, so loading isn’t needed. Size it with size (xs–lg, default sm) and name it with label.

<Loading size="sm" label="Syncing repositories" />

Runtime updates (Astro)

In Svelte, React, Vue and Solid a loading region follows its props — flip loading or variant in state and it re-renders. Astro renders once on the server, so it gets a runtime path instead: render the region with the reactive prop (which serializes its config into the HTML) and call updateLoadingElement() from @pindoba/astro-loading/runtime:

import { updateLoadingElement } from "@pindoba/astro-loading/runtime";

updateLoadingElement(regionElement, { loading: false });
updateLoadingElement(regionElement, { variant: "busy" });

The patch re-runs the connect in the browser and updates the region’s classes, data-loading, aria-busy, the content’s inert and the status text in place; loading, variant, speed and progress are patchable, and a loading change honours the region’s delay / minDuration. A reactive region always renders its indicator and toggles its hidden state, so it can appear later without new markup. Every Astro demo on this page drives its switch and select this way. The same function works from vanilla JavaScript via @pindoba/core-loading.

  • Components
  • Blocks