Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    Patterns

    Loading

    When to show waiting, which indicator to pick and how long it stays.

    A short wait makes an indicator flash; a long one makes users think the page is stuck. This pattern fixes which indicator appears after how long, and how long it stays.

    • When an action or a data load does not return its result at once.
    • When a region of the page (card, table, list) loads for the first time.
    • When an action started by a button can take several seconds.

    Start the three jobs: the 600 ms job shows no indicator, the 2000 ms job shows a spinner in the button, and the 6000 ms job adds a ProgressBar with the percentage. The card shows a skeleton after the first second.

    Live example, StorybookOpen in Storybook
    Actions
    Phase reached: No indicator
    Phase reached: No indicator
    Phase reached: No indicator

    Project summary

    import { Button, Card, Inline, ProgressBar, Skeleton, Stack, Text } from '@bigbang/ui';
    import { useEffect, useRef, useState } from 'react';
    import { type Lang, LangProvider } from '../lang';
    const copy = {
    tr: {
    actions: 'İşlemler',
    run: (ms: number) => `${ms} ms süren işi başlat`,
    reached: 'Ulaşılan aşama',
    phases: { idle: 'Gösterge yok', waiting: 'Gösterge yok (1 sn beklenir)', spinner: 'Dönen gösterge', progress: 'İlerleme çubuğu' },
    progress: 'Kayıtlar hazırlanıyor',
    progressText: (percent: number) => `%${percent} tamamlandı`,
    cardTitle: 'Proje özeti',
    cardText: 'Bu ay 12 rapor tamamlandı, 3 rapor incelemede bekliyor.',
    reload: 'Özeti yenile',
    loadLabel: 'Özet yükleniyor',
    },
    en: {
    actions: 'Actions',
    run: (ms: number) => `Start the ${ms} ms job`,
    reached: 'Phase reached',
    phases: { idle: 'No indicator', waiting: 'No indicator (waits 1 s)', spinner: 'Spinner', progress: 'Progress bar' },
    progress: 'Preparing records',
    progressText: (percent: number) => `${percent}% complete`,
    cardTitle: 'Project summary',
    cardText: '12 reports were completed this month and 3 reports are waiting for review.',
    reload: 'Reload summary',
    loadLabel: 'Loading summary',
    },
    };
    export type LoadingPhase = 'idle' | 'waiting' | 'spinner' | 'progress';
    /** Under 1 s nothing shows, 1 to 3 s a spinner or skeleton, over 3 s a determinate `ProgressBar` with a label. */
    export const SPINNER_AFTER = 1000;
    export const PROGRESS_AFTER = 3000;
    /** Once an indicator shows, it stays at least this long so it does not flicker. */
    export const MIN_DISPLAY = 500;
    /** Maps a loading flag to the indicator that should show now. */
    export function useLoadingPhase(loading: boolean): LoadingPhase {
    const [phase, setPhase] = useState<LoadingPhase>('idle');
    const shownAtRef = useRef<number | null>(null);
    useEffect(() => {
    if (loading) {
    shownAtRef.current = null;
    const toSpinner = setTimeout(() => {
    shownAtRef.current = Date.now();
    setPhase('spinner');
    }, SPINNER_AFTER);
    const toProgress = setTimeout(() => setPhase('progress'), PROGRESS_AFTER);
    return () => {
    clearTimeout(toSpinner);
    clearTimeout(toProgress);
    };
    }
    // Nothing was shown, so the phase is still idle.
    if (shownAtRef.current === null) return;
    const remaining = Math.max(0, MIN_DISPLAY - (Date.now() - shownAtRef.current));
    const hide = setTimeout(() => {
    shownAtRef.current = null;
    setPhase('idle');
    }, remaining);
    return () => clearTimeout(hide);
    }, [loading]);
    // Before the first timer fires the work is under way but nothing shows yet.
    return loading && phase === 'idle' ? 'waiting' : phase;
    }
    const rank: Record<LoadingPhase, number> = { idle: 0, waiting: 1, spinner: 2, progress: 3 };
    function Job({ ms, lang }: { ms: number; lang: Lang }) {
    const t = copy[lang];
    const [running, setRunning] = useState(false);
    const [percent, setPercent] = useState(0);
    const [reached, setReached] = useState<LoadingPhase>('idle');
    const phase = useLoadingPhase(running);
    // The furthest phase of this run; adjusting state during render avoids an extra effect pass.
    if (rank[phase] > rank[reached]) setReached(phase);
    useEffect(() => {
    if (!running) return;
    const started = Date.now();
    const tick = setInterval(() => setPercent(Math.min(99, Math.floor(((Date.now() - started) / ms) * 100))), 250);
    const done = setTimeout(() => setRunning(false), ms);
    return () => {
    clearInterval(tick);
    clearTimeout(done);
    };
    }, [running, ms]);
    const start = () => {
    if (running) return;
    setReached('waiting');
    setPercent(0);
    setRunning(true);
    };
    return (
    <Stack gap="xs">
    <Inline gap="md" align="center" wrap>
    <Button variant="secondary" loading={phase === 'spinner' || phase === 'progress'} onClick={start}>
    {t.run(ms)}
    </Button>
    <Text size="sm" tone="subtle">
    {t.reached}: {t.phases[reached]}
    </Text>
    </Inline>
    {phase === 'progress' ? (
    <ProgressBar label={t.progress} value={percent} valueText={t.progressText(percent)} />
    ) : null}
    </Stack>
    );
    }
    function SummaryCard({ lang }: { lang: Lang }) {
    const t = copy[lang];
    const [loading, setLoading] = useState(true);
    const phase = useLoadingPhase(loading);
    useEffect(() => {
    if (!loading) return;
    const done = setTimeout(() => setLoading(false), 2000);
    return () => clearTimeout(done);
    }, [loading]);
    const busy = loading || phase !== 'idle';
    return (
    <Card>
    <Card.Header>
    <Card.Title>{t.cardTitle}</Card.Title>
    <Card.Actions>
    <Button variant="tertiary" size="sm" disabled={busy} onClick={() => setLoading(true)}>
    {t.reload}
    </Button>
    </Card.Actions>
    </Card.Header>
    <Card.Body>
    <div aria-busy={busy} className="min-h-16">
    {phase === 'waiting' ? null : (
    <Skeleton loading={busy} lines={3} label={t.loadLabel}>
    <Text>{t.cardText}</Text>
    </Skeleton>
    )}
    </div>
    </Card.Body>
    </Card>
    );
    }
    /**
    * Three jobs of different length show which indicator each one reaches; a content card shows a skeleton
    * that appears only after 1 s and reveals the content when the load ends.
    */
    export function LoadingStates({ lang = 'tr' }: { lang?: Lang }) {
    const t = copy[lang];
    return (
    <LangProvider lang={lang}>
    <Stack gap="xl" className="w-full max-w-xl">
    <Stack gap="md">
    <Text weight="medium">{t.actions}</Text>
    <Job ms={600} lang={lang} />
    <Job ms={2000} lang={lang} />
    <Job ms={6000} lang={lang} />
    </Stack>
    <SummaryCard lang={lang} />
    </Stack>
    </LangProvider>
    );
    }
    Rule Value
    Under 1 s Show nothing; a short wait would only flash.
    1 to 3 s A Spinner inside the control for actions (Button loading), a Skeleton for content.
    Over 3 s A ProgressBar: determinate when progress is known (percentage or step), indeterminate when it is not (no value); its label says what is being done.
    Minimum display Once shown, an indicator stays at least 500 ms.
    First load Use a skeleton for the first load of a region; content fades in over 110 ms.
    Action The spinner sits inside the control; the button keeps its width.
    Scope A section load never blocks the whole page.
    Region flag aria-busy="true" on the loading region.
    Saving
    DoThe action shows inside the button; the button stays in place.
    Don'tA disabled button without an indicator does not say the action is running.
    Loading summary
    DoA skeleton holds the place of the content on first load.
    Don'tAn empty area does not tell whether content is coming.
    1. The work starts. For the first second the screen shows no indicator.
    2. At 1 s, if the work continues, the indicator appears: a Spinner for actions, a Skeleton for content.
    3. At 3 s, if the work continues, a progress bar is added; its label says what is happening and its value the percentage: “Preparing records”, 40%. When progress is unknown the bar stays indeterminate and sweeps.
    4. When the work ends, the indicator goes away after it has been visible for at least 500 ms; the result arrives.
    5. If the result is already visible, nothing more is announced. If it is not (an export, for example), the end is announced once.

    While a section loads, the rest of the page stays usable. If the load fails, continue with the Error recovery pattern.

    • The loading region carries aria-busy="true" and loses it when the work ends.
    • One polite announcement is made at the start: “Loading”. Skeleton and Spinner do this themselves.
    • The end is announced only when the result is not visible another way; otherwise screen readers repeat needlessly.
    • ProgressBar has role="progressbar"; its label is the name and the percentage the value (valueText reads “40% complete”). The value announcement updates at meaningful intervals, not on every step.
    • An indicator never relies on colour alone; text or an accessible name is always present.
    • Say what is loading: “Preparing records”, “Loading summary”.
    • Give the percentage and the step together: “40% complete · Preparing records”.
    • Skip filler sentences; state only what is happening.
    Item Motion Preset
    Skeleton 1500 ms shimmer, 3 loops, then still motion.skeleton
    Content arrival 110 ms fade motion.reveal
    Spinner and indeterminate bar 1000 ms linear loop and sweep motion.indeterminate
    Determinate progress 150 ms motion.progress

    With reduced motion the shimmer and the spin stop; text and accessible name still describe the state.

    Button, ProgressBar, Spinner, Skeleton, Card, Text. Related patterns: Saving and Error recovery.