Skeleton

Reserve the shape of incoming content without announcing each placeholder. Text follows the surrounding line metrics; circles and rectangles stand in for avatars and media. Use one status message and aria-busy on the loading region, then replace the placeholders with real content. Keep existing content visible during refresh when possible. A broad shimmer sweeps across the placeholders together by default, continuously from left to right. Reduced-motion preferences always keep it static.

Loading text

Placeholders reserve real line boxes while content loads.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-stack/ui-stack.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-skeleton/ui-skeleton.html"
>

<ui-stack
  gap="xs"
>
  <ui-skeleton></ui-skeleton>
  <ui-skeleton
    width="85%"
  ></ui-skeleton>
  <ui-skeleton
    width="60%"
  ></ui-skeleton>
</ui-stack>
<script setup>
  import { UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<template>
  <UiStack
    gap="xs"
  >
    <UiSkeleton />
    <UiSkeleton
      width="85%"
    />
    <UiSkeleton
      width="60%"
    />
  </UiStack>
</template>
import { UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="xs"
  >
    <UiSkeleton />
    <UiSkeleton
      width="85%"
    />
    <UiSkeleton
      width="60%"
    />
  </UiStack>;
}
/** @jsxImportSource preact */
import { UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="xs"
  >
    <UiSkeleton />
    <UiSkeleton
      width="85%"
    />
    <UiSkeleton
      width="60%"
    />
  </UiStack>;
}
<script>
  import { UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<UiStack
  gap="xs"
>
  <UiSkeleton />
  <UiSkeleton
    width="85%"
  />
  <UiSkeleton
    width="60%"
  />
</UiStack>
import { Component } from "@angular/core";
import { UiSkeleton, UiStack } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiStack, UiSkeleton],
  template: `
    <div
      ui-stack
      [gap]="'xs'"
    >
      <span
        ui-skeleton
      ></span>
      <span
        ui-skeleton
        [width]="'85%'"
      ></span>
      <span
        ui-skeleton
        [width]="'60%'"
      ></span>
    </div>
  `,
})
export default class Example {}

Text lines

Each placeholder reserves a whole text line. Vary the widths to suggest the content without pretending to know its words.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-stack/ui-stack.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-skeleton/ui-skeleton.html"
>

<ui-stack
  gap="none"
>
  <ui-skeleton></ui-skeleton>
  <ui-skeleton
    width="85%"
  ></ui-skeleton>
  <ui-skeleton
    width="60%"
  ></ui-skeleton>
</ui-stack>
<script setup>
  import { UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<template>
  <UiStack
    gap="none"
  >
    <UiSkeleton />
    <UiSkeleton
      width="85%"
    />
    <UiSkeleton
      width="60%"
    />
  </UiStack>
</template>
import { UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="none"
  >
    <UiSkeleton />
    <UiSkeleton
      width="85%"
    />
    <UiSkeleton
      width="60%"
    />
  </UiStack>;
}
/** @jsxImportSource preact */
import { UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="none"
  >
    <UiSkeleton />
    <UiSkeleton
      width="85%"
    />
    <UiSkeleton
      width="60%"
    />
  </UiStack>;
}
<script>
  import { UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<UiStack
  gap="none"
>
  <UiSkeleton />
  <UiSkeleton
    width="85%"
  />
  <UiSkeleton
    width="60%"
  />
</UiStack>
import { Component } from "@angular/core";
import { UiSkeleton, UiStack } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiStack, UiSkeleton],
  template: `
    <div
      ui-stack
      [gap]="'none'"
    >
      <span
        ui-skeleton
      ></span>
      <span
        ui-skeleton
        [width]="'85%'"
      ></span>
      <span
        ui-skeleton
        [width]="'60%'"
      ></span>
    </div>
  `,
})
export default class Example {}

Avatar and media shapes

The default circle matches a medium Avatar. Supply dimensions for a thumbnail or a larger media area; narrow containers constrain the width.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-stack/ui-stack.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-cluster/ui-cluster.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-skeleton/ui-skeleton.html"
>

<ui-stack
  gap="m"
>
  <ui-cluster
    gap="m"
  >
    <ui-skeleton
      shape="circle"
    ></ui-skeleton>
    <ui-skeleton
      shape="circle"
      width="1.75rem"
    ></ui-skeleton>
    <ui-skeleton
      shape="rectangle"
      width="6rem"
      height="2.5rem"
    ></ui-skeleton>
  </ui-cluster>
  <ui-skeleton
    shape="rectangle"
  ></ui-skeleton>
</ui-stack>
<script setup>
  import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<template>
  <UiStack
    gap="m"
  >
    <UiCluster
      gap="m"
    >
      <UiSkeleton
        shape="circle"
      />
      <UiSkeleton
        shape="circle"
        width="1.75rem"
      />
      <UiSkeleton
        shape="rectangle"
        width="6rem"
        height="2.5rem"
      />
    </UiCluster>
    <UiSkeleton
      shape="rectangle"
    />
  </UiStack>
</template>
import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="m"
  >
    <UiCluster
      gap="m"
    >
      <UiSkeleton
        shape="circle"
      />
      <UiSkeleton
        shape="circle"
        width="1.75rem"
      />
      <UiSkeleton
        shape="rectangle"
        width="6rem"
        height="2.5rem"
      />
    </UiCluster>
    <UiSkeleton
      shape="rectangle"
    />
  </UiStack>;
}
/** @jsxImportSource preact */
import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="m"
  >
    <UiCluster
      gap="m"
    >
      <UiSkeleton
        shape="circle"
      />
      <UiSkeleton
        shape="circle"
        width="1.75rem"
      />
      <UiSkeleton
        shape="rectangle"
        width="6rem"
        height="2.5rem"
      />
    </UiCluster>
    <UiSkeleton
      shape="rectangle"
    />
  </UiStack>;
}
<script>
  import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<UiStack
  gap="m"
>
  <UiCluster
    gap="m"
  >
    <UiSkeleton
      shape="circle"
    />
    <UiSkeleton
      shape="circle"
      width="1.75rem"
    />
    <UiSkeleton
      shape="rectangle"
      width="6rem"
      height="2.5rem"
    />
  </UiCluster>
  <UiSkeleton
    shape="rectangle"
  />
</UiStack>
import { Component } from "@angular/core";
import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiStack, UiCluster, UiSkeleton],
  template: `
    <div
      ui-stack
      [gap]="'m'"
    >
      <div
        ui-cluster
        [gap]="'m'"
      >
        <span
          ui-skeleton
          [shape]="'circle'"
        ></span>
        <span
          ui-skeleton
          [shape]="'circle'"
          [width]="'1.75rem'"
        ></span>
        <span
          ui-skeleton
          [shape]="'rectangle'"
          [width]="'6rem'"
          [height]="'2.5rem'"
        ></span>
      </div>
      <span
        ui-skeleton
        [shape]="'rectangle'"
      ></span>
    </div>
  `,
})
export default class Example {}

Loading a profile

One status describes the work; the section stays busy until the application replaces its decorative placeholders. Compare the loaded card's avatar and text metrics.

Loading a profile…
?

Ada Lovelace

Mathematician

Notes on an analytical engine and the possibilities of computation.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-stack/ui-stack.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-status-message/ui-status-message.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-grid/ui-grid.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-card/ui-card.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-cluster/ui-cluster.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-skeleton/ui-skeleton.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-avatar/ui-avatar.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-text/ui-text.html"
>

<ui-stack
  gap="m"
>
  <ui-status-message
    kind="loading"
  >Loading a profile…</ui-status-message>
  <ui-grid>
    <ui-card
      as="section"
      aria-label="Profile"
      aria-busy="true"
    >
      <ui-cluster
        gap="s"
      >
        <ui-skeleton
          shape="circle"
        ></ui-skeleton>
        <ui-stack
          fill
          gap="none"
        >
          <ui-skeleton
            width="65%"
          ></ui-skeleton>
          <ui-skeleton
            width="45%"
          ></ui-skeleton>
        </ui-stack>
      </ui-cluster>
      <ui-stack
        gap="none"
      >
        <ui-skeleton></ui-skeleton>
        <ui-skeleton
          width="80%"
        ></ui-skeleton>
      </ui-stack>
    </ui-card>
    <ui-card
      as="section"
      aria-label="Loaded profile"
    >
      <ui-cluster
        gap="s"
      >
        <ui-avatar
          name="Ada Lovelace"
          decorative
        ></ui-avatar>
        <ui-stack
          fill
          gap="none"
        >
          <ui-text
            weight="semibold"
          >Ada Lovelace</ui-text>
          <ui-text
            tone="secondary"
          >Mathematician</ui-text>
        </ui-stack>
      </ui-cluster>
      <ui-text>Notes on an analytical engine and the possibilities of computation.</ui-text>
    </ui-card>
  </ui-grid>
</ui-stack>
<script setup>
  import {
    UiAvatar,
    UiCard,
    UiCluster,
    UiGrid,
    UiSkeleton,
    UiStack,
    UiStatusMessage,
    UiText,
  } from "@threadlabs/looma";
</script>

<template>
  <UiStack
    gap="m"
  >
    <UiStatusMessage
      kind="loading"
    >Loading a profile…</UiStatusMessage>
    <UiGrid>
      <UiCard
        as="section"
        aria-label="Profile"
        aria-busy="true"
      >
        <UiCluster
          gap="s"
        >
          <UiSkeleton
            shape="circle"
          />
          <UiStack
            :fill="true"
            gap="none"
          >
            <UiSkeleton
              width="65%"
            />
            <UiSkeleton
              width="45%"
            />
          </UiStack>
        </UiCluster>
        <UiStack
          gap="none"
        >
          <UiSkeleton />
          <UiSkeleton
            width="80%"
          />
        </UiStack>
      </UiCard>
      <UiCard
        as="section"
        aria-label="Loaded profile"
      >
        <UiCluster
          gap="s"
        >
          <UiAvatar
            name="Ada Lovelace"
            :decorative="true"
          />
          <UiStack
            :fill="true"
            gap="none"
          >
            <UiText
              weight="semibold"
            >Ada Lovelace</UiText>
            <UiText
              tone="secondary"
            >Mathematician</UiText>
          </UiStack>
        </UiCluster>
        <UiText>Notes on an analytical engine and the possibilities of computation.</UiText>
      </UiCard>
    </UiGrid>
  </UiStack>
</template>
import {
  UiAvatar,
  UiCard,
  UiCluster,
  UiGrid,
  UiSkeleton,
  UiStack,
  UiStatusMessage,
  UiText,
} from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="m"
  >
    <UiStatusMessage
      kind="loading"
    >Loading a profile…</UiStatusMessage>
    <UiGrid>
      <UiCard
        as="section"
        aria-label="Profile"
        aria-busy="true"
      >
        <UiCluster
          gap="s"
        >
          <UiSkeleton
            shape="circle"
          />
          <UiStack
            fill
            gap="none"
          >
            <UiSkeleton
              width="65%"
            />
            <UiSkeleton
              width="45%"
            />
          </UiStack>
        </UiCluster>
        <UiStack
          gap="none"
        >
          <UiSkeleton />
          <UiSkeleton
            width="80%"
          />
        </UiStack>
      </UiCard>
      <UiCard
        as="section"
        aria-label="Loaded profile"
      >
        <UiCluster
          gap="s"
        >
          <UiAvatar
            name="Ada Lovelace"
            decorative
          />
          <UiStack
            fill
            gap="none"
          >
            <UiText
              weight="semibold"
            >Ada Lovelace</UiText>
            <UiText
              tone="secondary"
            >Mathematician</UiText>
          </UiStack>
        </UiCluster>
        <UiText>Notes on an analytical engine and the possibilities of computation.</UiText>
      </UiCard>
    </UiGrid>
  </UiStack>;
}
/** @jsxImportSource preact */
import {
  UiAvatar,
  UiCard,
  UiCluster,
  UiGrid,
  UiSkeleton,
  UiStack,
  UiStatusMessage,
  UiText,
} from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="m"
  >
    <UiStatusMessage
      kind="loading"
    >Loading a profile…</UiStatusMessage>
    <UiGrid>
      <UiCard
        as="section"
        aria-label="Profile"
        aria-busy="true"
      >
        <UiCluster
          gap="s"
        >
          <UiSkeleton
            shape="circle"
          />
          <UiStack
            fill
            gap="none"
          >
            <UiSkeleton
              width="65%"
            />
            <UiSkeleton
              width="45%"
            />
          </UiStack>
        </UiCluster>
        <UiStack
          gap="none"
        >
          <UiSkeleton />
          <UiSkeleton
            width="80%"
          />
        </UiStack>
      </UiCard>
      <UiCard
        as="section"
        aria-label="Loaded profile"
      >
        <UiCluster
          gap="s"
        >
          <UiAvatar
            name="Ada Lovelace"
            decorative
          />
          <UiStack
            fill
            gap="none"
          >
            <UiText
              weight="semibold"
            >Ada Lovelace</UiText>
            <UiText
              tone="secondary"
            >Mathematician</UiText>
          </UiStack>
        </UiCluster>
        <UiText>Notes on an analytical engine and the possibilities of computation.</UiText>
      </UiCard>
    </UiGrid>
  </UiStack>;
}
<script>
  import {
    UiAvatar,
    UiCard,
    UiCluster,
    UiGrid,
    UiSkeleton,
    UiStack,
    UiStatusMessage,
    UiText,
  } from "@threadlabs/looma";
</script>

<UiStack
  gap="m"
>
  <UiStatusMessage
    kind="loading"
  >Loading a profile…</UiStatusMessage>
  <UiGrid>
    <UiCard
      as="section"
      aria-label="Profile"
      aria-busy="true"
    >
      <UiCluster
        gap="s"
      >
        <UiSkeleton
          shape="circle"
        />
        <UiStack
          fill
          gap="none"
        >
          <UiSkeleton
            width="65%"
          />
          <UiSkeleton
            width="45%"
          />
        </UiStack>
      </UiCluster>
      <UiStack
        gap="none"
      >
        <UiSkeleton />
        <UiSkeleton
          width="80%"
        />
      </UiStack>
    </UiCard>
    <UiCard
      as="section"
      aria-label="Loaded profile"
    >
      <UiCluster
        gap="s"
      >
        <UiAvatar
          name="Ada Lovelace"
          decorative
        />
        <UiStack
          fill
          gap="none"
        >
          <UiText
            weight="semibold"
          >Ada Lovelace</UiText>
          <UiText
            tone="secondary"
          >Mathematician</UiText>
        </UiStack>
      </UiCluster>
      <UiText>Notes on an analytical engine and the possibilities of computation.</UiText>
    </UiCard>
  </UiGrid>
</UiStack>
import { Component } from "@angular/core";
import {
  UiAvatar,
  UiCard,
  UiCluster,
  UiGrid,
  UiSkeleton,
  UiStack,
  UiStatusMessage,
  UiText,
} from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiStack, UiStatusMessage, UiGrid, UiCard, UiCluster, UiSkeleton, UiAvatar, UiText],
  template: `
    <div
      ui-stack
      [gap]="'m'"
    >
      <div
        ui-status-message
        [kind]="'loading'"
      >Loading a profile…</div>
      <div
        ui-grid
      >
        <ng-template
          ui-card
          [as]="'section'"
          [hnAttributes]="{ 'aria-label': 'Profile', 'aria-busy': 'true' }"
        >
          <div
            ui-cluster
            [gap]="'s'"
          >
            <span
              ui-skeleton
              [shape]="'circle'"
            ></span>
            <div
              ui-stack
              [fill]="true"
              [gap]="'none'"
            >
              <span
                ui-skeleton
                [width]="'65%'"
              ></span>
              <span
                ui-skeleton
                [width]="'45%'"
              ></span>
            </div>
          </div>
          <div
            ui-stack
            [gap]="'none'"
          >
            <span
              ui-skeleton
            ></span>
            <span
              ui-skeleton
              [width]="'80%'"
            ></span>
          </div>
        </ng-template>
        <ng-template
          ui-card
          [as]="'section'"
          [hnAttributes]="{ 'aria-label': 'Loaded profile' }"
        >
          <div
            ui-cluster
            [gap]="'s'"
          >
            <span
              ui-avatar
              [name]="'Ada Lovelace'"
              [decorative]="true"
            ></span>
            <div
              ui-stack
              [fill]="true"
              [gap]="'none'"
            >
              <ng-template
                ui-text
                [weight]="'semibold'"
              >Ada Lovelace</ng-template>
              <ng-template
                ui-text
                [tone]="'secondary'"
              >Mathematician</ng-template>
            </div>
          </div>
          <ng-template
            ui-text
          >Notes on an analytical engine and the possibilities of computation.</ng-template>
        </ng-template>
      </div>
    </div>
  `,
})
export default class Example {}

Shared shimmer

One broad gradient stays fixed to the viewport, so every placeholder reveals the same moving highlight. It loops continuously and stops for reduced motion.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-stack/ui-stack.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-cluster/ui-cluster.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-skeleton/ui-skeleton.html"
>

<ui-stack
  gap="s"
>
  <ui-cluster
    gap="s"
  >
    <ui-skeleton
      shape="circle"
    ></ui-skeleton>
    <ui-skeleton
      width="50%"
    ></ui-skeleton>
  </ui-cluster>
  <ui-skeleton
    shape="rectangle"
    width="75%"
    height="3rem"
  ></ui-skeleton>
</ui-stack>
<script setup>
  import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<template>
  <UiStack
    gap="s"
  >
    <UiCluster
      gap="s"
    >
      <UiSkeleton
        shape="circle"
      />
      <UiSkeleton
        width="50%"
      />
    </UiCluster>
    <UiSkeleton
      shape="rectangle"
      width="75%"
      height="3rem"
    />
  </UiStack>
</template>
import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="s"
  >
    <UiCluster
      gap="s"
    >
      <UiSkeleton
        shape="circle"
      />
      <UiSkeleton
        width="50%"
      />
    </UiCluster>
    <UiSkeleton
      shape="rectangle"
      width="75%"
      height="3rem"
    />
  </UiStack>;
}
/** @jsxImportSource preact */
import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";

export default function Example() {
  return <UiStack
    gap="s"
  >
    <UiCluster
      gap="s"
    >
      <UiSkeleton
        shape="circle"
      />
      <UiSkeleton
        width="50%"
      />
    </UiCluster>
    <UiSkeleton
      shape="rectangle"
      width="75%"
      height="3rem"
    />
  </UiStack>;
}
<script>
  import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";
</script>

<UiStack
  gap="s"
>
  <UiCluster
    gap="s"
  >
    <UiSkeleton
      shape="circle"
    />
    <UiSkeleton
      width="50%"
    />
  </UiCluster>
  <UiSkeleton
    shape="rectangle"
    width="75%"
    height="3rem"
  />
</UiStack>
import { Component } from "@angular/core";
import { UiCluster, UiSkeleton, UiStack } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiStack, UiCluster, UiSkeleton],
  template: `
    <div
      ui-stack
      [gap]="'s'"
    >
      <div
        ui-cluster
        [gap]="'s'"
      >
        <span
          ui-skeleton
          [shape]="'circle'"
        ></span>
        <span
          ui-skeleton
          [width]="'50%'"
        ></span>
      </div>
      <span
        ui-skeleton
        [shape]="'rectangle'"
        [width]="'75%'"
        [height]="'3rem'"
      ></span>
    </div>
  `,
})
export default class Example {}

API

Package: @threadlabs/looma · Native root: span

Attributes

NameDescriptionPropertyTypeDefaultAllowed values
animatedMoves a subtle highlight from left to right. On by default; pass false to keep it static. Reduced-motion preferences always disable the shimmer.animatedbooleantrue—
heightBlock size as a CSS length. Empty reserves one line for text, a square for circles, or 8rem for rectangles. Set only width to keep a circle square.heightstring—
shapeThe content's silhouette. text reserves one inherited line box with a shorter bar inside it; circle matches a medium Avatar; rectangle reserves a media area.shapekeywordtexttext, circle, rectangle
widthInline size as a CSS length or percentage, constrained to the container. Empty fills the available width for text and rectangles, or matches a medium Avatar's outer size for circles.widthstring—

Framework props

NameTypeDefaultAllowed values
animatedbooleantrue—
heightstring—
shape"text" | "circle" | "rectangle"texttext, circle, rectangle
widthstring—

Events

No events.

Slots

No slots.

Design tokens

Set shared theme properties on a theme boundary. Component hooks apply only to their owning component.

Component tokens

TokenDeclared valuesFallback
--ui-skeleton-height—1lh | auto | 8rem
--ui-skeleton-width—100% | calc(2.5rem + 2 * var(--ui-border-width))

Shared tokens

TokenFallback
--ui-border-width—
--ui-radius-md—
--ui-radius-round—
--ui-radius-sm—
--ui-text-primary—