Export Button
An export menu for a data table — row scope, a column subset, a live size estimate on every format, and a row that generates, ticks or turns into Retry without the menu closing under it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/export-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ExportButton" component using
lucide-react icons and the shadcn dropdown-menu primitive (Radix). It is the
export control of a data table: which rows, which columns, which format — and
then the file being produced, reported inside the menu that asked for it.
Contract
- Export a forwardRef <button> (the menu trigger) extending
React.ButtonHTMLAttributes<HTMLButtonElement> plus:
rowCount: number rows in the dataset, ignoring every filter
filteredCount?: number default rowCount
selectedCount?: number default 0
columns?: ExportColumn[] default [] — empty hides the picker
formats?: ExportFormat[] default CSV / JSON / Excel / PDF
onExport: (request: ExportRequest) => void | Promise<unknown>
scope / defaultScope / onScopeChange "all" | "filtered" | "selected"
columnIds / defaultColumnIds / onColumnIdsChange string[]
open / defaultOpen / onOpenChange
bytesPerRow?: number default 96; used only when columns is empty
label?: string default "Export" (trigger text)
heading?: string default "Export table" (menu heading;
deliberately not `title`, which is the
native attribute on the trigger)
align?: "start" | "center" | "end" default "start"
resetMs?: number default 2400
contentClassName?: string classes for the portalled panel
- ExportColumn = { id, label, bytesPerCell?: number (default 12), required?: boolean }
- ExportFormat = { id, label, extension?, description?, icon?, maxRows?, unavailable? }
- ExportRequest = { format, scope, rows, columns: string[], estimatedBytes }
- Scope, column set and open state each ride the value / defaultValue / onChange
triad through one useControllableState hook; the component keeps no shadow
copy of a controlled value. Only the per-format run state is internal — it
belongs to the run, not to the caller.
- Also export formatExportSize(bytes) and formatRowCount(n) — the row labels are
useless to a consumer's own toast or log if the formatting lives privately.
Behavior
- Scope resolution. Build the option list from the three counts and offer only
the ones that mean a different file: "All rows" always; "Current filter" only
when filteredCount < rowCount; "Selected rows" only when selectedCount > 0.
With one option the radio group is not rendered at all. `rows` is the count of
the effective scope. If the controlled scope names an option that no longer
exists (the selection was cleared underneath it) fall back to "all" for
rendering without rewriting the caller's prop. defaultScope, read once on
mount, is the narrowest scope that exists — that is what the reader was
looking at when they reached for Export.
- Column set. Chosen = columns.filter(c => c.required || columnIds.includes(c.id)),
in declaration order, never click order: the file's column order must not
depend on which checkbox was ticked first. Unknown ids are dropped and
required ones folded back in, so a controlled parent cannot ship a request
that disagrees with what the picker showed. A required column's toggle is a
no-op guarded in the handler.
- Size estimate, recomputed every render (it is arithmetic, and it must track
both pickers):
C = chosen column count
payload = Σ over chosen columns of (bytesPerCell ?? 12)
keys = Σ over chosen columns of (id.length + 4) // keyed formats only
perRow = w.perRow + C·w.perCell + payload·w.swell + (w.keyed ? keys : 0)
bytes ≈ w.base + w.header·C + rows·perRow
with weights
csv { base 64, header 14, perRow 2, perCell 1, swell 1.05 }
json { base 96, header 0, perRow 26, perCell 5, swell 1.25, keyed }
xlsx { base 12288, header 32, perRow 44, perCell 4, swell 0.55 }
pdf { base 18432, header 40, perRow 96, perCell 26, swell 1.8 }
unknown id { base 512, header 16, perRow 12, perCell 3, swell 1.1 }
base is the container overhead before a single row is written; header is the
one-off cost of naming a column, zero for JSON because it repeats the key on
every row and pays through `keys` instead; perCell is the envelope around a
value (a comma, a pair of quotes, a cell tag, a text operator); swell is what
happens to the value itself — escaping pushes it above 1, and XLSX is a zip
archive so it lands well below. Every number is prefixed with "~" so it never
reads as a promise. When `columns` is empty the estimator substitutes one
synthetic row: payload = bytesPerRow, C = round(bytesPerRow / 12).
- Running. Selecting a format always calls event.preventDefault() so the menu
stays open — progress belongs next to the row that started it. A one-shot lock
lives in a ref that is read AND written synchronously inside the handler
(`if (runRef.current) return; runRef.current = { id, token }`); a state flag
would still hold its old value on a double click inside one tick. Freeze the
whole request — format, scope, rows, columns, estimatedBytes — before calling
onExport, so flipping a radio mid-run changes neither what is being produced
nor what the finished row reports. Wrap the call in try/catch AND settle
through Promise.resolve(result).then(ok, fail), so a synchronous throw, a
rejected promise and a plain undefined return are all handled and nothing ever
escapes as an unhandled rejection.
- Outcomes. pending → spinner + "Generating…"; resolve → tick + "Ready · N rows
· ~size" that clears itself after resetMs; reject → the error message + "select
to retry" in destructive ink, kept until the reader acts on it. Success
expires, failure does not: fading away is an acceptable end for good news only.
While one row runs, the others go aria-disabled; the running row keeps its own
name and its own busy state, and the trigger carries aria-busy.
- Refusals, checked in this order and shown in place of the size: an explicit
`unavailable` reason; rows === 0; no column ticked; rows > format.maxRows,
which names both numbers ("240,000 rows is over the 5,000-row ceiling for
PDF"). A refused row is inert but never removed — a reason the reader cannot
reach is not a reason.
- Keyboard. Trigger: Enter / Space / ArrowDown open. Menu: ArrowUp / ArrowDown
rove, Home / End jump, printable characters typeahead onto the item text,
Escape closes and returns focus to the trigger, Tab closes it (the menu is
non-modal). Scope radios and format rows activate on Enter / Space and none of
them close the menu. Columns submenu: ArrowRight or Enter opens, ArrowLeft or
Escape closes back onto its trigger row, Space toggles a checkbox and the
submenu stays open — picking a column set is several clicks, and a menu that
shuts after each one turns a ten-second decision into ten round trips.
- ARIA. The trigger gets aria-busy while a run is live plus an aria-label that
restates the request ("Export, 12,480 rows · 6 of 8 columns"), so the menu's
value is available before it is opened. Blocked rows use aria-disabled and a
guard in onSelect, NEVER the primitive's `disabled` prop: a disabled menu item
is skipped by roving focus, which makes the reason unreachable for exactly the
people who need it read out. Each format row is labelled "Export as <label>,
<summary>, <state>" where state ∈ about <size> / generating / finished /
failed, select it again to retry / unavailable, <reason> / unavailable while
another export is running. A required column announces "always exported"; an
optional one announces "exported" or "left out". One polite role="status"
region — rendered OUTSIDE the portalled panel, because a slow export usually
lands after the menu is gone — announces results and every scope or column
change, and empties itself after 4 s so the same sentence can be announced
twice. The right-hand size / spinner / tick column is aria-hidden: it repeats
what the label already carries.
- Degenerate cases. rowCount <= 0 renders a sentence instead of four formats
that would each produce a header and nothing else — no scope group, no picker,
no rows. filteredCount = 0 with rowCount > 0 leaves the filter scope on offer
(it does differ from All rows) but every format refuses with the reason.
Unticking the last column is only reachable when nothing is `required`, and it
turns the submenu counter destructive. NaN, negative and fractional counts are
rounded and clamped into 0..rowCount.
- Cleanup. One reset timer per finished row in a Map, one announcement timer,
one mounted flag. Unmount clears the whole Map and both timers. A promise that
lands after unmount returns early instead of arming a timer nothing will ever
clear, and a superseded run is fenced by a monotonic token compared against
the ref before anything is written. Re-arm the mounted flag in the effect body
rather than only initialising it: StrictMode runs the cleanup once before the
real mount, and a flag only ever set to false would silently swallow every
result for the rest of the session in development.
Rendering & styling
- Semantic tokens only. Trigger = the outline button variant. Panel: the
primitive's popover surface, w-80, p-1.5. Heading text-sm font-medium over a
text-xs text-muted-foreground summary. Muted descriptions and size text;
text-destructive for a refusal reason, an error line and the 0/8 column
counter; text-primary for the success tick. No hardcoded colours anywhere —
the whole menu inherits the host's popover tokens and gets dark mode free.
- Numbers are formatted without toLocaleString: the server and the browser can
resolve different default locales, and "12,480" against "12 480" is a
hydration mismatch on every export button on the page. A regex thousands
separator is deterministic. Everything numeric is tabular-nums.
- The size / spinner / tick share one fixed-width right column (w-16) so a row
never re-flows as it changes state, and long labels truncate rather than
wrapping the row open.
- Reduced motion: motion-reduce:animate-none on both spinners and
motion-reduce:transition-none on the chevron. Nothing about the export depends
on animation — with motion off the chevron simply flips and the spinner holds.
- Merge the consumer's className into the trigger via cn(); contentClassName is
the separate handle on the portalled panel, because the two are not the same
box and merging them into one prop makes the panel impossible to size.
Customization levers
- Weights table: FORMAT_WEIGHT is the whole pricing model. Add a row for your own
format id (Parquet, Avro, a signed archive) rather than special-casing the
component; unknown ids already fall back to the generic weights, so a new
format is priced sanely on day one.
- Column costs: bytesPerCell is the honest lever for "this column is expensive".
A free-text note is nearer 300 than 12, and setting it is what makes "untick
the note and the CSV drops by half" visible before the click.
- Ceilings: maxRows is where a real writer limit goes — worksheet rows, a
renderer's page budget, a plan quota. Leave it undefined and the format never
refuses on size.
- Scope vocabulary: the three scope labels are the whole i18n surface for the
radio group; keep them short enough that the count on the right stays on the
same line.
- Density: drop the per-format `description` for a compact two-line row, or drop
the extension chip for a narrower panel. Both are cosmetic and neither is load
bearing.
- Trigger: pass `children` for a fully custom trigger (an icon-only button in a
toolbar) — the busy spinner, the label and the chevron are the default only.
Swap buttonVariants({ variant: "outline" }) for "default" where Export is the
primary action of the view.
- resetMs governs how long a tick lingers; raise it when the file lands in a
download shelf the reader has to go find, lower it in a dense toolbar.Concepts
- A scope only exists when it means a different file — "Current filter" appears only while the filter actually removes rows and "Selected rows" only while something is ticked, so the group never offers two names for one export; when just one survives, the radio group is not rendered at all.
- The column set is a price lever, not a checklist — every estimate is recomputed from the chosen columns, which turns "do I need the internal note?" into a number the reader can see change instead of a guess they make after waiting for the file.
- A refusal names the number that caused it — over a writer's row ceiling, a filter matching nothing, or no column ticked: the row goes inert with the reason in place of the size, and stays focusable, so the explanation reaches keyboard and screen-reader users rather than being a dimmed dead end.
- The request is frozen at the click — format, scope, row count, columns and the estimate are captured before
onExportruns, so flipping a radio while a PDF renders changes neither the file being produced nor what the finished row reports. - A ref lock, not a state lock — the one-shot guard is read and written synchronously inside the handler, because a
useStateflag still holds its old value when a second click lands in the same tick, and two runs would race to write the same row. - Success expires, failure does not — the tick clears itself after
resetMswhile an error row keeps its reason and turns into Retry in place; fading away is an acceptable end for good news only, and the menu never closes under either.
Toggle Group
A row or column of independent aria-pressed toggles — one tab stop per option, optional min/max limits that refuse out loud, and per-item disabled reasons.
Oauth Buttons
A third-party sign-in row whose brand colours arrive as props — one provider goes in flight, the rest lock without ever going natively disabled, and the last-used one stays marked.