GrSkeleton

Package: @feugene/granularitycoreGroup: feedback

A temporary interface skeleton while the content is still loading.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • there is no content yet — the placeholder repeats its shape, and the layout does not jump when the data appears;
  • the first render is loading — a card, a list, a table: the user sees the structure before the data;
  • there are several placeholderscount repeats a row the required number of times;
  • the shape is non-standardvariant gives a line, a block or a circle for an avatar.

When to take something else

NeedTake
The content is already there but is being updatedGrLoading
The share of what is done is knownGrProgressBar
The loading has finished and there is no dataGrEmptyState
The wait is inside a buttonGrButton with loading

A skeleton is honest only when it repeats the future layout. Three identical lines instead of a table of six columns deceive twice: first they promise the wrong thing, then they give a jump of the layout.

The shape by variant, the sizes by the consumer

variantThe radiusWhat for
text (the default)--gr-radius-fulla line of text
rect--gr-radius-mda block: a card, a button, a picture
circle--gr-radius-fullan avatar, an icon

The variant sets the shape only. width and height stay with the consumer: the height of a placeholder is dictated by the content it stands in for rather than by the scale of the component. The default is 100% × 12px, and 2.5rem for a circle.

The circle is the only shape where the sizes are linked: height repeats width until it is set explicitly. Otherwise one side that was set would turn the circle into an oval.

rounded overrides the radius of the variant pointwise — for instance when the card of an application has a large radius of its own.

A block of several lines

<GrSkeleton :count="3" />

count draws N placeholders in a shared grid. In text the last line is shorter (60%): the block reads as a paragraph rather than as a list of identical bars. In rect and circle all of the elements are identical — they have no “unfinished line”. A width that has been set is stronger than that rule.

A single placeholder (the default count) renders without a wrapper — with exactly the same node as before.

Accessibility

The placeholder as a whole is marked aria-hidden="true" — both a single one and the wrapper of a group. There is no point announcing an “empty” node; the fact that loading is going on is reported by the container: aria-busy="true" and a live region with text. That is how it is done inside the package — see the loading state of GrStatistic and GrTable.

The movement

The pulsing is a @keyframes of the component’s own, and under prefers-reduced-motion: reduce it is switched off entirely (animation: none) rather than sped up: an endless animation is movement the user has asked not to be shown. The whole contract of movement — ../motion.md.

The <style> of the component is deliberately not scoped: the rule hangs on the [data-gr-skeleton] attribute, so it reaches placeholders rendered inside other components of the package as well.

Playground 5

Loading…

Code
<GrSkeleton />

Install

npm i @feugene/granularity

Import

import { GrSkeleton } from '@feugene/granularity/components/GrSkeleton'

API

Props

PropTypedefaultDescription
variant"circle" | "text" | "rect" | undefined"text"The shape rather than the dimensions: `width`/`height` stay with the consumer, because the height of a placeholder is dictated by the neighbouring content rather than by the scale of the component.
widthstring | undefinedundefined
heightstring | undefinedundefined
roundedstring | undefinedundefinedThe radius pointwise. Unset — it is taken from `variant`.
countnumber | undefined1How many placeholders in a row. More than one and they go into a common wrapper.

Examples 4

Shape and repetition

variant="text"
variant="rect"
variant="circle"
:count="4"

Variants
<script setup lang="ts">
import { GrSkeleton } from '@feugene/granularity'
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-3 sm:grid-cols-3">
      <div class="grid gap-2">
        <span class="text-xs text-[var(--gr-muted-fg)]">variant="text"</span>
        <GrSkeleton />
      </div>

      <div class="grid gap-2">
        <span class="text-xs text-[var(--gr-muted-fg)]">variant="rect"</span>
        <GrSkeleton variant="rect" height="40px" />
      </div>

      <div class="grid gap-2">
        <span class="text-xs text-[var(--gr-muted-fg)]">variant="circle"</span>
        <GrSkeleton variant="circle" width="40px" />
      </div>
    </div>

    <div class="grid gap-2">
      <span class="text-xs text-[var(--gr-muted-fg)]">:count="4"</span>
      <GrSkeleton :count="4" />
    </div>
  </div>
</template>

Text card placeholder

Text Card
<script setup lang="ts">
import { GrCard, GrSkeleton } from '@feugene/granularity'
</script>

<template>
  <GrCard class="grid gap-4 p-4">
    <GrSkeleton variant="rect" width="38%" height="20px" />

    <!-- Три строки одним пропом: последняя короче, поэтому блок читается абзацем. -->
    <GrSkeleton :count="3" />
  </GrCard>
</template>

Avatar/list row placeholders

List
<script setup lang="ts">
import { GrSkeleton } from '@feugene/granularity'

const rows = [1, 2, 3]
</script>

<template>
  <div class="grid gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
    <div v-for="row in rows" :key="row" class="flex items-center gap-3">
      <GrSkeleton variant="circle" width="44px" />

      <div class="min-w-0 flex-1 grid gap-2">
        <GrSkeleton width="44%" />
        <GrSkeleton width="72%" />
      </div>

      <GrSkeleton variant="rect" width="72px" height="28px" />
    </div>
  </div>
</template>

Dashboard and chart layout

Dashboard
<script setup lang="ts">
import { GrSkeleton } from '@feugene/granularity'
</script>

<template>
  <div class="grid gap-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
    <div class="grid gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <GrSkeleton width="26%" height="18px" rounded="12px" />
      <GrSkeleton height="160px" rounded="18px" />
      <div class="grid gap-2 sm:grid-cols-3">
        <GrSkeleton v-for="card in 3" :key="card" height="72px" rounded="16px" />
      </div>
    </div>

    <div class="grid gap-3 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <GrSkeleton width="48%" height="18px" rounded="12px" />
      <GrSkeleton :count="4" width="100%" />
    </div>
  </div>
</template>

Component documentationAll components