Buttons

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Braces,
  Check,
  ChevronDown,
  Columns3,
  Download,
  FileSpreadsheet,
  FileText,
  Loader2,
  RotateCcw,
  Table2,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/export-button.json

Prompt

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 onExport runs, 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 useState flag 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 resetMs while 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.

On This Page