Skip to content
KONIGI Patterns

Dashboards / Dashboard features / Loading and skeleton state

‹ 8 of 10 ›

Loading and skeleton state

People need to know that data is still loading rather than missing or unavailable.

Updated September 10, 2026

Problem

While panels load, blank areas can look like finished results with no data.

Solution

Reserve the space each panel will occupy and show a clear loading state. Replace it without moving content that people are already reading.

Keep loading distinct from an empty result or error. Use a text or status cue as well as any animation, and respect reduced-motion preferences.

Detect failed or stalled requests instead of leaving placeholders indefinitely. During refreshes, keep useful existing data visible with its age and refresh status.

Use when

Loading takes long enough to notice, especially when panels finish at different times.

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

Placeholders need to match the final layout and can drift as it changes. Many animations add distraction and rendering work. Loading feedback helps explain a wait but does not replace fixing slow 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 shows a completed request with no results. Error and stale state handles failed or outdated results. Dense small-multiple layout makes independent loading especially visible.

Freshness indicator supports showing old data during a refresh. Panel grid keeps the layout stable.

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

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Grafana SLO / SLO Overview, loading September 11, 2026 Grafana Play (signed out; captured 1.2s after navigation) sparse · dark · desktop-web
This capture shows the SLO Overview shortly after navigation. A loading message appears beside the navigation rail, but the future rows have no placeholder space. The completed page appeared a few seconds later. The message identifies loading clearly, though this screenshot cannot establish how the page handles a failed or stalled request.