Skip to content
KONIGI

Dashboards / Product mechanics / Loading and skeleton state

8 of 10

Loading and skeleton state

Forty panels are querying and the page shouldn't look broken for two seconds.

Updated September 10, 2026

Problem

The dashboard has forty panels and each one runs its own query. For the first second or two the page is a grid of empty rectangles, and an empty rectangle is indistinguishable from a panel whose query returned nothing.

Solution

Show the shape of what is coming, sized correctly, with a signal that work is happening. Then replace it in place, without the layout moving.

Nielsen’s three response-time limits set the bar and they have not changed since 1993. Under 0.1 second the system feels instantaneous and needs no feedback. Under 1 second the user’s flow of thought stays unbroken, though they notice; still no special feedback needed. Past 10 seconds attention is gone, and the interface owes a percent-done indicator and an estimate so the person can do something else meanwhile.

Dashboards live almost entirely in the awkward middle, and the middle is where skeletons belong: too slow to feel instant, too fast for a progress bar to be honest.

Three requirements decide whether the implementation helps.

Occupy the final space. A skeleton that is the wrong size makes the page reflow when data lands, which is worse than a blank because the thing the viewer was reading moves. This matters most on the dense layouts where the pattern matters most.

Be distinguishable from empty and from broken. Motion does this cheaply: a shimmer says working, stillness says finished. A static grey box says nothing and gets read as “no data” by anyone who arrives mid-load.

Fail into a state, not into permanence. A skeleton with no timeout is the worst outcome in the family, because it claims work is ongoing forever. Every loading state needs a deadline and an error state behind it.

Use when

Panels take longer than about a second, which is nearly all of them, and especially where many load independently and finish out of order.

Don’t use when

The response is genuinely fast. A skeleton that flashes for 80ms is visual noise and makes a fast page feel busy. Delay showing it by a couple of hundred milliseconds so quick responses never trigger it at all.

Trade-offs

Skeletons make a page feel designed and can make it feel slower, because a shimmering placeholder holds attention in a way a blank does not. They are extra markup that has to stay in sync with the real layout, so they drift and eventually stop matching. Forty animating placeholders is its own cost, on the GPU and on anyone sensitive to motion. And a well-made loading state can disguise a genuinely slow page, which removes the pressure to fix the queries.

Checklist

  • Is the skeleton the same size and position as the content it replaces?
  • Does the layout stay still when real data arrives?
  • Is loading distinguishable from empty, and from error, at a glance?
  • Is there a delay before showing it, so fast responses never flash?
  • Is there a timeout, and what state does it fall into?
  • Do panels load independently, and does the page look coherent half-loaded?
  • Does anything animate, and does it respect prefers-reduced-motion?
  • On a wallboard, what does a viewer twenty feet away see during load?
  • Does a refresh clear existing data to skeletons, or update in place?
  • Would showing the previous values, marked stale, serve better than a skeleton?

Compare

Grafana loads panels independently with a per-panel spinner, so a dashboard settles progressively and a slow panel is visibly the slow one rather than holding up the page. Netdata sidesteps the pattern by streaming: charts begin drawing as data arrives rather than waiting for a complete response, so there is little gap to fill. Sentry uses shaped skeletons that match its list rows closely enough that the transition to real content is nearly invisible, which is the pattern done well on a predictable layout. Honeycomb shows query progress rather than a placeholder, which suits a product where a query can legitimately take long enough that Nielsen’s ten-second rule applies and an estimate is owed.

Empty state is what a finished load with no results must look like, and the two are confused constantly. Error and stale state is where a loading state has to land when the deadline passes. Dense small-multiple layout is the context that makes this pattern load-bearing. Freshness indicator is the better answer when old data could be shown while new data loads. Panel grid is what has to hold still while everything arrives.

Loading and skeleton state anatomy A grid mid-load: skeletons occupying exactly the space their panels will take, one panel already landed, and nothing moving when the rest arrive. Below, Nielsen's three response-time limits, with the window where skeletons belong marked between one and ten seconds. Occupy the final space p95 latency 1 2 Response time 0.1s Feels instantaneous. No feedback needed. 1s Noticed, but thought is unbroken. Still nothing owed. 10s Attention is gone. Owes a percent-done and an estimate. dashboards live in here 1 SIZED TO WHAT'S COMING A skeleton of the wrong size makes the page reflow when data lands, which is worse than a blank: what you were reading moves. 2 NOT EMPTY, NOT BROKEN Motion does it cheaply: a shimmer says working, stillness says finished. A static grey box gets read as "no data". A skeleton with no timeout claims work is ongoing forever. Every loading state needs a deadline and an error state behind it.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Forty panels querying and the page must not look broken for two seconds. The skeleton has to match the shape that arrives, or the layout jumps and every reader loses their place at once.

shadcn
npx shadcn@latest add skeleton button
Tokens
--card--muted--muted-foreground--border--status-critical

p95 latency

LoadingPanel.tsxOne panel, three states. Fixed height in every state, a delay before the skeleton shows, and a deadline after which it fails into an error with a retry.

import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";

/** Three states, and loading is the only one with a clock on it. */
export type PanelState = "loading" | "ready" | "failed";

type Props = {
  title: string;
  state: PanelState;
  children?: ReactNode;
  /** Height in px, fixed for every state. The skeleton occupies the final
   *  space, so nothing on the page moves when the data lands. */
  height?: number;
  /** Under this, nothing shows: a fast query must never flash a skeleton. */
  delayMs?: number;
  /** Past this the panel stops claiming work is happening and fails into a
   *  state with a way out. A skeleton with no deadline is a lie that lasts. */
  timeoutMs?: number;
  onRetry: () => void;
  /** How long the query had been running when this mounted. Lets a server
   *  render show the skeleton rather than the pre-delay blank. */
  loadingFor?: number;
  className?: string;
};

const TICK = 250;

export function LoadingPanel({
  title, state, children, height = 70, delayMs = 300, timeoutMs = 10_000, onRetry, loadingFor = 0, className,
}: Props) {
  // Counted from mount rather than read from a clock, so the same markup
  // renders on the server and the browser and the deadline still fires.
  const [elapsed, setElapsed] = useState(loadingFor);
  useEffect(() => {
    if (state !== "loading") return;
    const t = setInterval(() => setElapsed((e) => e + TICK), TICK);
    return () => clearInterval(t);
  }, [state]);
  // A retry starts the clock again from zero, delay included.
  const prev = useRef(state);
  useEffect(() => {
    if (state === "loading" && prev.current !== "loading") setElapsed(0);
    prev.current = state;
  }, [state]);

  const timedOut = state === "failed" || elapsed >= timeoutMs;
  const showSkeleton = state === "loading" && !timedOut && elapsed >= delayMs;

  return (
    <div className={cn("rounded border bg-muted p-2.5", className)} style={{ height }} aria-busy={state === "loading"}>
      {state === "ready" && (
        <>
          <p className="text-[10px] text-muted-foreground">{title}</p>
          <div className="mt-2">{children}</div>
        </>
      )}
      {/* A shimmer says working; stillness would read as no data. */}
      {showSkeleton && (
        <>
          <Skeleton className="h-1.5 w-[58px] rounded-full bg-muted-foreground/20 motion-reduce:animate-none" />
          <Skeleton className="mt-2.5 h-[22px] w-[92px] bg-muted-foreground/20 motion-reduce:animate-none" />
        </>
      )}
      {timedOut && (
        <>
          <p className="text-[10px] text-muted-foreground">{title}</p>
          <p className="mt-1 text-[11px] text-status-critical">
            {state === "failed" ? "Query failed" : `No answer after ${Math.round(timeoutMs / 1000)}s`}
          </p>
          <Button variant="link" size="sm" className="h-auto p-0 text-[11px]" onClick={onRetry}>retry</Button>
        </>
      )}
    </div>
  );
}

demo.tsxHow it is called: five panels 1.2s in, one landed. Three more land over the next seconds; the last never answers and times out.

import { useEffect, useState } from "react";
import { LoadingPanel, type PanelState } from "./LoadingPanel";
import { Sparkline } from "../sparkline/Sparkline";

/**
 * A grid mid-load, 1.2s in: one panel landed, four still querying. The
 * others land one by one over the next seconds, and the last one never
 * answers, so it hits the 10s deadline and fails into a state with a retry.
 */
const LANDS: Record<string, number | null> = { errors: 900, throughput: 1800, routes: 2600, saturation: null };

export default function Demo() {
  const [state, setState] = useState<Record<string, PanelState>>({
    errors: "loading", throughput: "loading", routes: "loading", saturation: "loading",
  });
  const [run, setRun] = useState(0);

  useEffect(() => {
    const timers = Object.entries(LANDS).flatMap(([id, ms]) =>
      ms === null ? [] : [setTimeout(() => setState((s) => ({ ...s, [id]: "ready" })), ms)]);
    return () => timers.forEach(clearTimeout);
  }, [run]);

  const retry = (id: string) => {
    setState((s) => ({ ...s, [id]: "loading" }));
    setRun((r) => r + 1);
  };
  const common = { loadingFor: 1200 };

  return (
    <div className="grid w-full max-w-[400px] grid-cols-3 gap-2.5 rounded-lg border bg-card p-4">
      <LoadingPanel title="p95 latency" state="ready" onRetry={() => {}} {...common}>
        <span className="text-muted-foreground"><Sparkline values={[38, 46, 40, 58, 52]} width={92} height={22} /></span>
      </LoadingPanel>
      <LoadingPanel title="Error rate" state={state.errors} onRetry={() => retry("errors")} {...common}>
        <p className="text-lg font-semibold tabular-nums text-card-foreground">0.42%</p>
      </LoadingPanel>
      <LoadingPanel title="Throughput" state={state.throughput} onRetry={() => retry("throughput")} {...common}>
        <p className="text-lg font-semibold tabular-nums text-card-foreground">1,284/s</p>
      </LoadingPanel>
      <LoadingPanel title="Latency by route" state={state.routes} onRetry={() => retry("routes")} className="col-span-2" {...common}>
        <span className="text-muted-foreground"><Sparkline values={[120, 132, 118, 141, 138, 126, 150, 144]} width={218} height={22} /></span>
      </LoadingPanel>
      <LoadingPanel title="Saturation" state={state.saturation} onRetry={() => retry("saturation")} {...common}>
        <p className="text-lg font-semibold tabular-nums text-card-foreground">71%</p>
      </LoadingPanel>
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Grafana

The reference implementation for panel grids, template variables, and stat panels; most other tools are defined by how they differ from it.

Grafana SLO / SLO Overview, loading September 11, 2026 Grafana Play (signed out; captured 1.2s after navigation) sparse · dark · desktop-web
The same SLO Overview as the other Grafana capture, 1.2 seconds after navigation. This is the whole loading state: the nav rail, and the words "Loading …" centred in an otherwise empty canvas. No skeleton, no panel frames, nothing occupying the space the twenty-eight rows are about to take. I tried to catch an intermediate frame and there isn't one—at 3.5 seconds the page is complete. It goes from nothing to everything, so the layout arrives all at once and anything a viewer had started reading moves. The entry asks for three things here and this delivers one. It is distinguishable from broken and from empty, because a spinner clearly means working. It does not occupy the final space. And whether it fails into an error state rather than spinning forever is not something a screenshot can answer, which is its own small argument for showing the shape of what is coming.
  • Loading and skeleton state A spinner and the word Loading. Correct that work is happening, silent about what is arriving or how much.
  • Loading and skeleton state The space twenty-eight rows are about to fill, holding nothing. When they land, everything below them moves.