Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    Patterns

    Data export

    Choosing the scope when exporting records, separating small and large exports and naming the file.

    Export lets users take a list as a file and use it in another tool. This pattern fixes the scope choice, the difference between small and large files and the file format.

    • When users want to use the records of a table in a spreadsheet or a report.
    • When the scope can vary: filtered records or all records.
    • To copy only a few rows, selection and Bulk actions are enough; no export is needed.

    Choose the scope, then press “Download as CSV”. 120 records download at once: the button shows loading, then the success mark. 12,000 records are prepared in the background: a progress bar fills, then a “File ready” toast arrives with a “Download” action and a download link appears. The example simulates these times with a timer (about 1500 ms). The “Failed” story in Storybook shows a failed export: the bar turns red and a danger toast offers “Try again”.

    Live example, StorybookOpen in Storybook
    Scope
    import { Button, Inline, Link, ProgressBar, Radio, RadioGroup, SplitButton, Stack, Text, useToast } from '@bigbang/ui';
    import { useEffect, useRef, useState } from 'react';
    import { type Lang, LangProvider } from '../lang';
    const copy = {
    tr: {
    scope: 'Kapsam',
    filtered: 'Filtrelenmiş 120 kayıt',
    all: 'Tüm 12.000 kayıt',
    csv: 'CSV olarak indir',
    excel: 'Excel olarak indir',
    pdf: 'PDF olarak indir',
    menu: 'Diğer dışa aktarma biçimleri',
    preparingLabel: 'Dosya hazırlanıyor',
    doneLabel: 'İndirildi',
    preparing: 'Dosya hazırlanıyor',
    ready: 'Dosya hazır',
    download: 'Dosyayı indir',
    downloaded: (name: string) => `${name} indirildi`,
    progress: 'Dışa aktarma ilerlemesi',
    failedStatus: 'Dışa aktarma başarısız',
    toastDownload: 'İndir',
    toastFailed: 'Dışa aktarma başarısız',
    toastFailedText: 'Dosya hazırlanamadı. Tekrar deneyin.',
    retry: 'Tekrar dene',
    base: 'kayit',
    },
    en: {
    scope: 'Scope',
    filtered: 'Filtered 120 records',
    all: 'All 12,000 records',
    csv: 'Download as CSV',
    excel: 'Download as Excel',
    pdf: 'Download as PDF',
    menu: 'Other export formats',
    preparingLabel: 'File is being prepared',
    doneLabel: 'Downloaded',
    preparing: 'Preparing the file',
    ready: 'File ready',
    download: 'Download the file',
    downloaded: (name: string) => `${name} downloaded`,
    progress: 'Export progress',
    failedStatus: 'Export failed',
    toastDownload: 'Download',
    toastFailed: 'Export failed',
    toastFailedText: 'The file could not be prepared. Try again.',
    retry: 'Try again',
    base: 'records',
    },
    };
    type Format = 'csv' | 'xlsx' | 'pdf';
    type Phase = 'idle' | 'small' | 'small-done' | 'preparing' | 'ready' | 'downloaded' | 'failed';
    const SMALL_MS = 800;
    const STEP_MS = 300;
    const STEPS = 5;
    // With `fail`, the background export stops at this step.
    const FAIL_STEP = 3;
    const SUCCESS_MS = 2000;
    // Up to 10.000 rows download directly; above that the file is prepared in the background.
    const DIRECT_LIMIT = 10000;
    const rowCount = { filtered: 120, all: 12000 } as const;
    /**
    * Export in two sizes. A small export (up to 10.000 rows) shows loading on the button, then the success mark and a status line.
    * A large export runs in the background: a progress bar fills while the file is prepared, then a toast offers the download.
    * `fail` makes the background export fail, to show the danger toast with a retry action.
    */
    export function DataExportDemo({ lang = 'tr', fail = false }: { lang?: Lang; fail?: boolean }) {
    return (
    <LangProvider lang={lang}>
    <ExportFlow lang={lang} fail={fail} />
    </LangProvider>
    );
    }
    function ExportFlow({ lang, fail }: { lang: Lang; fail: boolean }) {
    const t = copy[lang];
    const toast = useToast();
    const [scope, setScope] = useState<keyof typeof rowCount>('filtered');
    const [phase, setPhase] = useState<Phase>('idle');
    const [fileName, setFileName] = useState('');
    const [progress, setProgress] = useState(0);
    const timersRef = useRef<number[]>([]);
    useEffect(() => () => timersRef.current.forEach((timer) => window.clearTimeout(timer)), []);
    const later = (action: () => void, ms: number) => {
    timersRef.current.push(window.setTimeout(action, ms));
    };
    const run = (format: Format) => {
    timersRef.current.forEach((timer) => window.clearTimeout(timer));
    timersRef.current = [];
    const name = `${t.base}-2026-10-28.${format}`;
    setFileName(name);
    setProgress(0);
    if (rowCount[scope] <= DIRECT_LIMIT) {
    setPhase('small');
    later(() => setPhase('small-done'), SMALL_MS);
    later(() => setPhase('idle'), SMALL_MS + SUCCESS_MS);
    return;
    }
    setPhase('preparing');
    for (let step = 1; step <= STEPS; step += 1) {
    later(() => {
    if (fail && step === FAIL_STEP) {
    setPhase('failed');
    toast.show({ title: t.toastFailed, description: t.toastFailedText, tone: 'danger', action: { label: t.retry, onClick: () => run(format) } });
    return;
    }
    setProgress((step / STEPS) * 100);
    if (step === STEPS) {
    setPhase('ready');
    toast.show({ title: t.ready, description: name, tone: 'success', action: { label: t.toastDownload, onClick: () => setPhase('downloaded') } });
    }
    }, step * STEP_MS);
    }
    };
    const status = phase === 'preparing' ? t.preparing : phase === 'ready' ? t.ready : phase === 'small-done' || phase === 'downloaded' ? t.downloaded(fileName) : phase === 'failed' ? t.failedStatus : '';
    return (
    <Stack gap="lg" className="max-w-md">
    <RadioGroup label={t.scope} value={scope} onValueChange={(next) => setScope(next === 'all' ? 'all' : 'filtered')}>
    <Radio value="filtered" label={t.filtered} />
    <Radio value="all" label={t.all} />
    </RadioGroup>
    <Inline gap="md" align="center" wrap>
    {phase === 'small' ? (
    <Button loading loadingLabel={t.preparingLabel}>
    {t.csv}
    </Button>
    ) : phase === 'small-done' ? (
    <Button success successLabel={t.doneLabel}>
    {t.csv}
    </Button>
    ) : (
    <SplitButton
    menuLabel={t.menu}
    onClick={() => run('csv')}
    actions={[
    { label: t.excel, onSelect: () => run('xlsx') },
    { label: t.pdf, onSelect: () => run('pdf') },
    ]}
    >
    {t.csv}
    </SplitButton>
    )}
    {phase === 'ready' ? (
    <Link href="#download" onClick={() => setPhase('downloaded')}>
    {t.download}
    </Link>
    ) : null}
    </Inline>
    {phase === 'preparing' || phase === 'failed' ? (
    <div>
    <ProgressBar label={t.progress} value={progress} tone={phase === 'failed' ? 'danger' : 'brand'} size="sm" />
    </div>
    ) : null}
    <Text size="sm" tone="subtle" role="status">
    {status}
    </Text>
    </Stack>
    );
    }
    Rule Value
    Main button “Download as CSV”; the menu beside it holds “Download as Excel” and “Download as PDF”.
    Scope Radio group: “Filtered 120 records” or “All 12,000 records”. The numbers are visible.
    Direct download Up to 10,000 rows. The button shows loading, then the success mark (2000 ms).
    Background Above 10,000 rows the file is prepared in the background; a ProgressBar shows the progress. When it is done, a Toast with a “Download” action and a download link arrive. On failure, a danger Toast offers “Try again”.
    Filters, sort, columns The export follows the current filters, sort order and visible columns.
    File name <record-type>-<YYYY-MM-DD>.csv, for example records-2026-10-28.csv.
    Hidden fields Hidden or masked fields stay masked in the file.
    File format CSV in UTF-8 with a BOM; dates in ISO 8601.
    Filtered 120 records
    DoThe scope is stated with its record count.
    Data
    Don'tA scope without a count does not say what will download.
    DoThe link says what the file is.
    Don'tA vague button text does not describe the action.
    1. The user chooses the scope. The default scope is the filtered records.
    2. The user presses the main button or a format in the menu.
    3. With 10,000 rows or fewer, the button turns to loading, the file downloads and the button shows the success mark for 2000 ms. The status text gives the file name.
    4. With more than 10,000 rows, the job starts in the background. The status reads “Preparing the file” and a progress bar fills; the user keeps working on the page.
    5. When the file is ready, the status reads “File ready”, a toast with a “Download” action appears and a “Download the file” link stays.
    6. If the file cannot be prepared, a danger toast says so and offers “Try again”; a danger toast stays until it is closed.

    If filters or sort order change during an export, the running job continues with the settings it started with.

    • The menu button shows only an arrow; its name is “Other export formats”.
    • The scope is a radio group with a <fieldset> and a legend; arrow keys change the option.
    • The status text sits in a polite status region. “Preparing the file” and “File ready” are announced without moving focus (WCAG 4.1.3).
    • The loading and success states of the button are announced to screen readers as text.
    • The download link is a real link and reachable by keyboard.
    • Buttons start with a verb: “Download as CSV”, “Download as Excel”.
    • Status text is short and in the present or past tense: “Preparing the file”, “File ready”.
    • The file name holds the record type and the date; it holds no personal data.
    • Scope numbers use thousands separators: “12,000”.
    Item Motion Preset
    Button loading 1000 ms linear spinner loop Spinner component
    Success mark 0.6 to 1 scale over 240 ms, held 2000 ms motion.success
    Menu 150 ms open, 110 ms close motion.popover

    With reduced motion, scales and shifts are removed and the success mark shows static.

    SplitButton, RadioGroup, ProgressBar, Toast, Button, Link, Text. Related patterns: Filtering, Bulk actions.