Skeleton and Spinner

Skeleton and Spinner show that content is loading. Use a skeleton when you know the layout, and a spinner when you don’t.

  • Beta
  • Accessibility
  • Figma Spinner 2920:88383
  • packages/ui/src/components/Skeleton · Spinner

Beta until design reviews the code-only skeleton (Figma has none) and a looping-duration token is approved (C34). The spinner follows Figma 2920:88383; its 16, 20 and 32px sizes are code-only.

Preview

Try each skeleton shape and animation, and each spinner size.

Loading lessons
tsx
<div aria-busy="true">
  <Skeleton lines={3} />
</div>

<Spinner size={24} label="Loading lessons" />

Installation

Availability

@nuton/ui and @nuton/tokens are private workspace packages and aren't published yet (D4, D12). Inside the Nuton monorepo, add them as workspace dependencies.

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react
tsx
import '@nuton/tokens/tokens.css'
import '@nuton/ui/styles.css'

Usage

tsx
import { Skeleton, Spinner } from '@nuton/ui'

// Known layout: draw its shape, and mark the region busy.
<section aria-labelledby="lessons-heading" aria-busy={loading}>
  <h2 id="lessons-heading">Recent lessons</h2>
  {loading ? <Skeleton lines={3} /> : <LessonList />}
</section>

// Unknown layout or a short wait:
<Spinner label="Loading lessons" />
  • Skeleton shapes are hidden from screen readers. Put aria-busy="true" on the region that is loading, and remove it when the content arrives.
  • Tell screen reader users when loading finishes: update a role="status" message, or render a Spinner with a specific label while loading.
  • Match the skeleton to the real content: same number of lines, the same text size, the same avatar size. Don't show more placeholders than real items.
  • Show a skeleton or spinner only when loading takes longer than about 300ms, so fast responses don't flash.
  • Inside a button, use the Button loading prop, not a Spinner.

Variants and sizes

Skeleton shapes

Skeleton animation

Skeleton partMeasurement (code-only)
Text line, small14px bar in a 24px row (Paragraph/Small), radius 4
Text line, medium16px bar in a 28px row (Paragraph/Medium), radius 4
Last line60% wide when there is more than one line
RectFills the width, 80px tall by default, radius 8
Circle40px by default, height follows width
ColoursBg/Soft-200 with a Bg/Weak-50 shimmer band; pulse fades to 50% opacity
Motion1.5s loop; stops with prefers-reduced-motion

Spinner sizes

Loading, 16px example
16
Loading, 20px example
20
Loading, 24px example
24 (Figma)
Loading, 32px example
32
Spinner partMeasurement
GlyphRemix loader-2-line, 18px inside a 24px box (Figma)
ColourIcon/Strong-950 by default (Figma); set --nu-spinner-color to change it
SpeedOne turn every 0.8s, the same as the Button spinner; 2.4s with reduced motion

Not built: the “Loading” writing animation

Figma 2960:105052 shows a pen drawing a line. It uses a non-Remix pen glyph (D1) and has no defined use, so it isn't in the library. Design to confirm where it should appear.

States and interactions

Neither component is interactive. Their only states are loading and loaded, handled by the region around them.

Loading (skeleton)The region has aria-busy="true"; shapes are hidden from assistive technology.
Loading example
Loading (spinner)A role="status" region with a visually hidden label.
Reduced motionThe shimmer and pulse stop; the spinner slows to one turn every 2.4s.

Composition examples

Loading a lesson list

The list is aria-busy while skeleton rows match the real rows (40px avatar, two lines). A status message announces the result.

Recent lessons

Loading lessons…

API reference

Skeleton props

Skeleton props
PropTypeDefaultDescription
shape'text' | 'rect' | 'circle''text'Text lines, a block (cards, images) or a circle (avatars).
linesnumber1Number of text lines. With more than one, the last is 60% wide.
textSize'small' | 'medium''small'Matches Paragraph/Small or Paragraph/Medium text.
widthnumber | string—A number is px; a string is any CSS length. Text and rect fill the container by default; circle is 40px.
heightnumber | string—Rect height (default 80px) or text bar height. Circle height follows width.
animation'shimmer' | 'pulse' | 'none''shimmer'Stops under prefers-reduced-motion.
…div propsHTMLAttributes<HTMLDivElement>—className, style and other attributes. aria-hidden is always true.

Spinner props

Spinner props
PropTypeDefaultDescription
size16 | 20 | 24 | 3224Box size in px. 24 is Figma.
labelstring'Loading'Visually hidden text read by screen readers. Make it specific and translate it.
decorativebooleanfalseHides the spinner from assistive technology (no role, no text) when something else announces loading.
…span propsHTMLAttributes<HTMLSpanElement>—className, style and other attributes. The ref points at the outer span.

Accessibility

Keyboard

Neither component takes focus. Don't move focus to a loading region; keep it where it was.

Screen readers

Skeletons are aria-hidden. Put aria-busy on the loading region and announce the result in a status message. The spinner is a role="status" live region with a hidden label.

Focus

No focus styles: nothing here is interactive.

Contrast and colour

Skeleton shapes are decorative (Soft-200, 1.19:1), so WCAG 1.4.11 doesn't apply. The spinner is Icon/Strong-950, 17.93:1 on white. In forced-colours mode, shapes get an outline.

Touch and pointer

No targets. Disable controls that can't be used while loading, like the Reload button in the example.

You must: mark the loading region with aria-busy and tell screen reader users when loading finishes or fails.

Guidelines and related components

Use a skeleton for

  • Lists, cards and pages whose layout you know
  • First loads of content-heavy views

Use a spinner for

  • Short waits with an unknown result
  • Small areas, like a panel refreshing

Don't use either for

  • Progress you can measure (use Progress)
  • Buttons (use the Button loading prop)

Do and don't

DoMatch the real layout The page doesn't jump when the content arrives.
Don'tUse a big block for everything People can't tell what is loading, and the layout shifts.
Loading lessons (example)
DoGive the spinner a specific label “Loading lessons” says what is happening.
Don'tShow several spinners at once Each one is announced; use one for the region.