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 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.
<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.
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactimport '@nuton/tokens/tokens.css'
import '@nuton/ui/styles.css'Usage
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 specificlabelwhile 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
loadingprop, not a Spinner.
Variants and sizes
Skeleton shapes
<Skeleton lines={3} />
<Skeleton lines={2} textSize="medium" />
<Skeleton shape="rect" height={120} />
<Skeleton shape="circle" width={40} />Skeleton animation
<Skeleton lines={2} /> // shimmer (default)
<Skeleton lines={2} animation="pulse" />
<Skeleton lines={2} animation="none" />| Skeleton part | Measurement (code-only) |
|---|---|
| Text line, small | 14px bar in a 24px row (Paragraph/Small), radius 4 |
| Text line, medium | 16px bar in a 28px row (Paragraph/Medium), radius 4 |
| Last line | 60% wide when there is more than one line |
| Rect | Fills the width, 80px tall by default, radius 8 |
| Circle | 40px by default, height follows width |
| Colours | Bg/Soft-200 with a Bg/Weak-50 shimmer band; pulse fades to 50% opacity |
| Motion | 1.5s loop; stops with prefers-reduced-motion |
Spinner sizes
<Spinner size={16} />
<Spinner size={20} />
<Spinner size={24} /> // Figma
<Spinner size={32} />| Spinner part | Measurement |
|---|---|
| Glyph | Remix loader-2-line, 18px inside a 24px box (Figma) |
| Colour | Icon/Strong-950 by default (Figma); set --nu-spinner-color to change it |
| Speed | One 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.
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.
API reference
Skeleton props
| Prop | Type | Default | Description |
|---|---|---|---|
shape | 'text' | 'rect' | 'circle' | 'text' | Text lines, a block (cards, images) or a circle (avatars). |
lines | number | 1 | Number of text lines. With more than one, the last is 60% wide. |
textSize | 'small' | 'medium' | 'small' | Matches Paragraph/Small or Paragraph/Medium text. |
width | number | string | — | A number is px; a string is any CSS length. Text and rect fill the container by default; circle is 40px. |
height | number | string | — | Rect height (default 80px) or text bar height. Circle height follows width. |
animation | 'shimmer' | 'pulse' | 'none' | 'shimmer' | Stops under prefers-reduced-motion. |
…div props | HTMLAttributes<HTMLDivElement> | — | className, style and other attributes. aria-hidden is always true. |
Spinner props
| Prop | Type | Default | Description |
|---|---|---|---|
size | 16 | 20 | 24 | 32 | 24 | Box size in px. 24 is Figma. |
label | string | 'Loading' | Visually hidden text read by screen readers. Make it specific and translate it. |
decorative | boolean | false | Hides the spinner from assistive technology (no role, no text) when something else announces loading. |
…span props | HTMLAttributes<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)