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 to use
Section titled “When to use”- 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.
Live example
Section titled “Live example”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”.
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. |
Behaviour
Section titled “Behaviour”- The user chooses the scope. The default scope is the filtered records.
- The user presses the main button or a format in the menu.
- 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.
- 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.
- When the file is ready, the status reads “File ready”, a toast with a “Download” action appears and a “Download the file” link stays.
- 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.
Accessibility
Section titled “Accessibility”- 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.
Content
Section titled “Content”- 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”.
Motion
Section titled “Motion”| 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.
Components used
Section titled “Components used”SplitButton, RadioGroup, ProgressBar, Toast, Button, Link, Text. Related patterns: Filtering, Bulk actions.