Loading and skeleton state 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. npx shadcn@latest add skeleton button Tokens this needs: --card, --muted, --muted-foreground, --border, --status-critical The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // LoadingPanel.tsx ──────────────────────────────────────────────────────────────────────── 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 (
{title}
{title}
{state === "failed" ? "Query failed" : `No answer after ${Math.round(timeoutMs / 1000)}s`}
> )}0.42%
1,284/s
71%