/

React Loaders and Skeletons: What a Slow Screen Actually Needs

When to show nothing, when a skeleton beats a spinner, and what a streaming answer needs instead, worked through with the loading components the community bookmarked most.

Serafim Korablev
Serafim Korablev
@korablev

A loader is an apology for time you could not remove. That is worth saying first, because most of the effort in this part of an interface goes into making the apology prettier rather than into the two decisions that actually change how the wait feels: whether to show anything at all, and whether the thing you show has the shape of what is coming.

151 public components on 21st have "loader" in the name, 48 more say "spinner", 48 say "loading" and 38 say "skeleton". That is a lot of visual variety for a problem with roughly four correct answers, and the split between those four is the useful part of the catalogue.

Under a second: show nothing

The uncomfortable one. A spinner that appears and disappears inside 300ms reads as a flash of noise, and it makes a fast interaction feel broken rather than fast. Common guidance across interface literature puts the threshold for "instant" at around 100ms and the point where attention starts to wander at around a second, which leaves a band where the honest move is to do nothing and let the result land.

The practical version: delay the loader rather than the response. Render nothing for the first 200 to 400ms, and only then show the indicator. If the data arrives first, the reader never learns there was a wait. This is a setTimeout you clear on resolve, not a design decision, and it is missing from most implementations.

One to a few seconds: a skeleton, if you know the shape

A skeleton is worth more than a spinner in exactly one case: when you know the layout of what is arriving. It holds the space, so nothing jumps when the content lands, and it tells the reader what kind of thing is coming before it is there.

Animated Loading Skeleton by Anurag Mishra is the shaped version, a card outline with animated bars where the text will be. The rule that decides whether a skeleton earns its place is whether the outline matches the result: a skeleton that shows three rows and resolves into eleven is a layout shift with extra steps, and layout shift is the one loading mistake that has a metric pointed straight at it.

Use a skeleton for lists, cards, tables and profiles, where the shape is known. Do not use one for a search result count, a chart with unknown series, or anything whose height depends on the answer.

One to a few seconds: a spinner, when you do not

When the shape is unknown, a plain indicator is the right answer and the smallest one wins. Spinner by Hayden Bleasel is the unadorned case. Dot Loader by paceui and Luma Spin by Ritik Singh are the same idea with more personality, and CoreSpin Loader by Muhammad Kumail Ali sits in the same family.

Two details separate a spinner that works from one that annoys. It should be centred in the space the result will occupy, not in the viewport, or the reader's eye has to travel back. And it needs role="status" with a text label, visually hidden if you like, or a screen reader announces nothing at all while the screen sits empty.

Text that is still being written

Streaming changed what a loader is for. When tokens arrive one at a time, the wait is not empty, so the indicator's job shifts from filling silence to marking that the text on screen is not finished.

Text Shimmer by ibelick is the pattern most products land on: a gradient that travels across the words, with the duration and the spread of the sweep exposed as props. Animated Shiny Text by dillionverma is the same effect on a smaller label, and Animated Loading SVG + Text Shimmer by zochory pairs the sweep with a mark.

For a chat surface specifically, Message loading by jakobhoeg is the three-dot bubble that belongs in the transcript rather than over it, and AI Input With Loading by kokonutd puts the state in the composer: an auto-resizing textarea with a loading animation and a status message, so the input tells you it is working instead of a separate overlay doing it.

AI Loader by beratberkay and Progressive Flux Loader by Ruixen UI are the heavier takes, for the case where the model call is the product and the wait is the moment you have.

Past ten seconds: progress, or an explanation

A spinner that runs for half a minute is worse than no feedback, because it carries no information and no end. Past roughly ten seconds you owe the reader one of two things: a real percentage, or a sentence about what is happening.

If you have a countable unit, count it. "Processing 12 of 48" beats any animation. If you do not, name the step: uploading, converting, indexing. A determinate bar that is genuinely determinate is the strongest signal in this whole category, and a fake one that sits at 90% is the fastest way to lose the trust the rest of the page earned.

What actually makes a loader wrong

Five failures, in the order they show up in real code.

It shifts the layout. The indicator occupies a different box than the result, so everything below jumps when the data lands. Cumulative Layout Shift is a Core Web Vitals metric with a threshold, and loading states are one of the two usual causes. Give the loader the height its result will have.

It has no accessible name. A div that spins is invisible to a screen reader. The container needs role="status" (which implies aria-live="polite") and text inside it. When the content arrives, the same region should say so.

It ignores reduced motion. Every spin, pulse and shimmer in this article is animation, and a reader who has asked their system to reduce motion has asked for a reason. Wrap the keyframes in @media (prefers-reduced-motion: reduce) and fall back to a static state or a much slower fade.

It renders on the server and then again on the client. A loading state that only exists after hydration means the first paint is blank. Streaming server rendering with Suspense boundaries puts the fallback in the initial HTML, which is where it does the most good.

There are six of them on one screen. A page where every card loads independently produces a flicker field. Group related requests behind one boundary; one honest wait reads better than nine small ones racing.

Where else to look

The honest list of alternatives, because the answer is not always us:

SourceBest forTrade-off
shadcn/uiSkeleton and Progress, unstyled and dependableTwo primitives, no shaped skeletons and no streaming states
Tailwind CSSanimate-pulse and animate-spin, which cover most cases in one classYou are still building the shape yourself
React Loading SkeletonSkeletons that inherit size from the content they replaceA dependency for something CSS mostly does
21stStreaming, chat and AI-shaped states that primitives do not coverQuality varies by author, so preview before you take it

Taking one

Every component page has a live preview and the code. Installing goes through the shadcn CLI against our registry:

bash

That key comes from your 21st account, and installs require a membership. Set API_KEY_21ST once in your shell and the command works for anything in the catalogue. The 21st MCP covers the same ground without leaving the editor: ask Claude, Cursor or Codex for a loading state, get the previews inline, and let the agent write the file.

Browse loading components →

Frequently asked

Should I use a skeleton or a spinner?
A skeleton when you know the shape of what is arriving, a spinner when you do not. Skeletons hold the space so nothing jumps when the content lands, which is what makes them worth the extra markup for lists, cards, tables and profiles. For a result whose height depends on the answer, a skeleton is a layout shift with extra steps, and a small centred indicator is the honest choice.
How long should a loading state wait before appearing?
Roughly 200 to 400 milliseconds. Below about 100ms an interaction reads as instant, so an indicator that flashes in and out makes a fast response feel broken. Delay the loader rather than the response: start a timer, clear it when the data resolves, and most of your fast requests will never show one at all.
How do you make a loading state accessible?
Put the indicator in a container with role="status", which implies aria-live="polite", and give it real text inside, visually hidden if the design has no room. Announce the result in the same region when it arrives. Wrap every spin, pulse and shimmer in a prefers-reduced-motion query with a static fallback, because all of them are animation.
Why does my page jump when the content loads?
The loading state occupies a different box than the result. That is Cumulative Layout Shift, a Core Web Vitals metric with a threshold attached, and loading states are one of its two usual causes. Give the placeholder the height and width its result will have, reserve space for images with width and height attributes, and prefer one shaped skeleton over several independent spinners on one screen.

Published

Aug 20, 2026

Read time

6 min

Tags

GuideLoadingReactPerformance

Share