Skip to contentVibraUI
Foundation

Export

Rows as a CSV, a chart as a PNG, and the page on paper — each producing a real file.

Dependency-free and DOM-only, so a table or a chart can reach for it without dragging a component in behind it. rowsToCsv follows RFC 4180 — a field carrying a comma, a quote or a newline is quoted and its quotes doubled — and defuses a value a spreadsheet would run as a formula, because a CSV that executes when it is opened is a vulnerability rather than an export. The guarantee is that a raw number stays a number — a negative or an exponent is not defused — not that any number-looking string does: a formatted -1,234 is still written as text. tableToCsv reads a TanStack instance structurally (no import, no version pinned): the columns the reader can see, in the order shown, over the rows left after filtering and sorting. svgToPng copies the plot and writes every painted property onto the copy first — a lifted SVG has no stylesheet, so var(--chart-1) would resolve to nothing — then draws it into a canvas at 2x on an opaque ground; chartSurface finds that plot inside a card. downloadBlob and downloadText answer false rather than throwing where a browser cannot mint an object URL, so a call site never has to guard. printPage mounts the masthead — brand, title, range and as-of, as real text — prints, and takes it down again on afterprint; the print sheet — themes/print.css, which theme-vibra ships in its css field — is what shows it, hides the sidebar, the header and the export menu, and prints a dark page in the light palette. The one thing an installed consumer does not get is the @page margin, which shadcn's css object cannot carry.

Install

npx shadcn@latest add @vibra/export

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
rowsToCsv(rows, columns?) => string—Rows as CSV with a header row. Columns default to the first row's keys, in order.
tableToCsv(table) => string—The same, from a TanStack table: visible columns, filtered and sorted rows.
svgToPng(svg, { scale?, background? }) => Promise<Blob>—A chart as a PNG, with its computed paint inlined first.
chartSurface(root) => SVGSVGElement | null—The recharts plot inside a card, or null while it is still a skeleton.
downloadBlob / downloadText(filename, blob | text) => boolean—Hands the file over; false where the browser cannot.
exportFilename(name, extension, now?) => string—Slug plus the day: "recent-signups-2026-09-04.csv".
printPage({ brand?, title, range?, asOf? }) => boolean—Prints the page under a real masthead, and removes it afterwards.

Dependencies

Registry

Source

lib/export.ts
/**
 * The three exports a dashboard actually owes a reader: the rows as a file, the
 * chart as a picture, and the page on paper. Dependency-free and DOM-only, so
 * any table or chart can reach for it without dragging a component in behind it.
 */

/* -------------------------------------------------------------------------- */
/* CSV                                                                         */
/* -------------------------------------------------------------------------- */

export type CsvColumn = { key: string; label?: string }

// RFC 4180: a field carrying a comma, a quote or a newline is quoted, and a
// quote inside it is doubled. A spreadsheet reads a leading "=", "+", "-", "@",
// tab or carriage return as the start of a formula, so a value beginning with
// one is prefixed with a quote character the spreadsheet strips again.
const RISKY_START = /^[=+\-@\t\r]/

// Except that a negative number begins with "-" and is not a formula. Defusing
// those turned every loss in a profit-and-loss into text, which is a column of
// numbers a reader cannot sum — a worse outcome than the one being prevented.
const PLAIN_NUMBER = /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i

function csvField(value: unknown): string {
  if (value === null || value === undefined) return ""
  const raw = value instanceof Date ? value.toISOString() : String(value)
  const risky = RISKY_START.test(raw) && !PLAIN_NUMBER.test(raw)
  const safe = risky ? `'${raw}` : raw
  return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe
}

/**
 * Rows as CSV, header row included.
 *
 * Columns default to the keys of the first row, in their own order; pass them to
 * fix the order, drop a column, or print a label the key does not carry.
 */
export function rowsToCsv(
  rows: ReadonlyArray<Record<string, unknown>>,
  columns?: ReadonlyArray<CsvColumn>
): string {
  const cols: ReadonlyArray<CsvColumn> =
    columns ?? Object.keys(rows[0] ?? {}).map((key) => ({ key }))
  if (cols.length === 0) return ""
  const header = cols.map((column) => csvField(column.label ?? column.key))
  const body = rows.map((row) => cols.map((column) => csvField(row[column.key])))
  return [header, ...body].map((cells) => cells.join(",")).join("\n")
}

/**
 * The shape of a TanStack table this module reads — structural, so nothing here
 * imports the library or pins its version.
 */
export type CsvTable = {
  getVisibleLeafColumns: () => ReadonlyArray<{ id: string; columnDef?: { header?: unknown } }>
  getRowModel: () => { rows: ReadonlyArray<{ getValue: (id: string) => unknown }> }
}

/**
 * A TanStack table as CSV: the columns the reader can see, in the order they are
 * shown, over the rows left after filtering and sorting — what is on screen, not
 * what was fetched. A header that is a component rather than a string falls back
 * to the column id, because a React element is not a cell.
 */
export function tableToCsv(table: CsvTable): string {
  const columns = table.getVisibleLeafColumns().map((column) => ({
    key: column.id,
    label: typeof column.columnDef?.header === "string" ? column.columnDef.header : column.id,
  }))
  const rows = table.getRowModel().rows.map((row) => {
    const record: Record<string, unknown> = {}
    for (const column of columns) record[column.key] = row.getValue(column.key)
    return record
  })
  return rowsToCsv(rows, columns)
}

/* -------------------------------------------------------------------------- */
/* Handing the file over                                                       */
/* -------------------------------------------------------------------------- */

/** A filename with the date on it: "visitors-2026-09-04.csv". */
export function exportFilename(name: string, extension: string, now = new Date()): string {
  const slug = name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
  return `${slug || "export"}-${now.toISOString().slice(0, 10)}.${extension}`
}

/**
 * Saves a blob under a filename. A no-op outside a browser, and outside one that
 * can mint an object URL — a jsdom test, an SSR pass — so a caller never has to
 * guard the call site.
 */
export function downloadBlob(filename: string, blob: Blob): boolean {
  if (typeof document === "undefined" || typeof URL.createObjectURL !== "function") return false
  const url = URL.createObjectURL(blob)
  const link = document.createElement("a")
  link.href = url
  link.download = filename
  link.rel = "noopener"
  document.body.append(link)
  link.click()
  link.remove()
  // Revoked on the next frame: revoking synchronously races the download in
  // Safari, which has not finished reading the URL when click() returns.
  setTimeout(() => URL.revokeObjectURL(url), 0)
  return true
}

/** Saves text as a file — CSV by default. */
export function downloadText(
  filename: string,
  text: string,
  type = "text/csv;charset=utf-8"
): boolean {
  return downloadBlob(filename, new Blob([text], { type }))
}

/* -------------------------------------------------------------------------- */
/* PNG                                                                         */
/* -------------------------------------------------------------------------- */

// Every property a chart paints with. An SVG lifted out of the page loses the
// stylesheet that resolved var(--chart-1), so each one is read off the live
// element and written onto the copy as a literal before it is serialised.
const PAINTED = [
  "fill",
  "fill-opacity",
  "stroke",
  "stroke-width",
  "stroke-opacity",
  "stroke-dasharray",
  "stroke-linecap",
  "stroke-linejoin",
  "font-family",
  "font-size",
  "font-weight",
  "letter-spacing",
  "text-anchor",
  "opacity",
] as const

/** The chart inside an element — what `svgToPng` is normally handed. */
export function chartSurface(root: Element | null | undefined): SVGSVGElement | null {
  return root?.querySelector<SVGSVGElement>("svg.recharts-surface") ?? null
}

// fill and stroke have to arrive as plain sRGB. A copied SVG is rendered as an
// image, and an image has no document to resolve var(--chart-1) against — nor,
// in some engines, a colour parser for the oklch() the token resolves to. A 1x1
// canvas is the only reliable converter: it paints the colour and reads the
// pixel back.
const COLOR_PROPERTIES = new Set(["fill", "stroke"])

function makeResolver(): (value: string) => string {
  const canvas = document.createElement("canvas")
  canvas.width = canvas.height = 1
  const context = canvas.getContext("2d", { willReadFrequently: true })
  return (value: string) => {
    if (!context || !value || value === "none" || value.startsWith("url(")) return value
    context.clearRect(0, 0, 1, 1)
    // Two assignments: an unparseable colour leaves fillStyle at the first one,
    // which is how an invalid value is told from a real black.
    context.fillStyle = "#000000"
    context.fillStyle = value
    context.fillRect(0, 0, 1, 1)
    const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data
    return a === 255
      ? `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`
      : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`
  }
}

// next/font names a face and its metrics-matched fallback and stops there —
// `Geist, "Geist Fallback"` — and inside an image neither name resolves: an
// image has no document's @font-face to consult, so the UA falls back to its
// default, a serif, and the ticks of an exported plot come out in Times. A
// generic family at the end keeps the file in a sans. Only the last entry
// counts: a quoted name can contain the word ("Noto Serif") without being one.
const GENERIC_FAMILIES = new Set([
  "sans-serif",
  "serif",
  "monospace",
  "system-ui",
  "ui-sans-serif",
  "ui-serif",
  "ui-monospace",
  "ui-rounded",
  "cursive",
  "fantasy",
  "math",
  "emoji",
  "fangsong",
])

/** A font-family list that ends in a generic family, so it resolves anywhere. */
export function withGenericFamily(value: string): string {
  const last = value.split(",").pop()?.trim().toLowerCase() ?? ""
  return GENERIC_FAMILIES.has(last) ? value : `${value}, sans-serif`
}

function inlinePaint(source: Element, copy: Element, resolve: (value: string) => string) {
  const computed = getComputedStyle(source)
  for (const property of PAINTED) {
    const value = computed.getPropertyValue(property)
    if (!value) continue
    // Presentation attributes rather than a style attribute: they survive
    // serialisation into a data: URL without a stylesheet behind them.
    copy.setAttribute(
      property,
      COLOR_PROPERTIES.has(property)
        ? resolve(value)
        : property === "font-family"
          ? withGenericFamily(value)
          : value
    )
  }

  const sourceChildren = source.children
  const copyChildren = copy.children
  for (let i = 0; i < sourceChildren.length; i += 1) {
    inlinePaint(sourceChildren[i], copyChildren[i], resolve)
  }
}

export type SvgToPngOptions = {
  /** Pixels per CSS pixel; 2 is a retina-sharp file. */
  scale?: number
  /** Painted behind the chart. Defaults to the plane the chart is actually on. */
  background?: string
}

/**
 * The plane behind an element: the first ancestor that paints one.
 *
 * A chart exported from a dark page is drawn in the dark theme's own ink, so
 * the ground has to be the plane it was actually on — a white default would
 * hand the reader pale lines on paper, the numbers unreadable and the fault
 * invisible until they open the file. White is what an element with no painted
 * ancestor at all gets, and since a page paints its body, that is an element
 * outside the document rather than a page in either theme.
 */
function planeBehind(element: Element, resolve: (value: string) => string): string {
  for (let node: Element | null = element; node; node = node.parentElement) {
    const painted = resolve(getComputedStyle(node).backgroundColor)
    // rgba(...) here means "see through me"; a hex means the plane is opaque.
    if (painted.startsWith("#")) return painted
  }
  return "#ffffff"
}

/**
 * A chart as a PNG.
 *
 * The SVG is copied, every painted property is read off the live element and
 * written onto the copy as plain sRGB — a lifted SVG has no stylesheet, so
 * `var(--chart-1)` would resolve to nothing — and the copy is drawn into a
 * canvas over the plane the chart was actually sitting on. Rejects rather than
 * resolving an empty file when the browser cannot decode it.
 */
export async function svgToPng(svg: SVGSVGElement, options: SvgToPngOptions = {}): Promise<Blob> {
  const { scale = 2 } = options
  const resolve = makeResolver()
  const background = options.background ?? planeBehind(svg, resolve)
  const box = svg.getBoundingClientRect()
  const width = Math.max(1, Math.round(box.width || Number(svg.getAttribute("width")) || 0))
  const height = Math.max(1, Math.round(box.height || Number(svg.getAttribute("height")) || 0))

  const copy = svg.cloneNode(true) as SVGSVGElement
  copy.setAttribute("xmlns", "http://www.w3.org/2000/svg")
  copy.setAttribute("width", String(width))
  copy.setAttribute("height", String(height))
  inlinePaint(svg, copy, resolve)

  const markup = new XMLSerializer().serializeToString(copy)
  const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`

  const image = await new Promise<HTMLImageElement>((resolve, reject) => {
    const element = new Image()
    element.onload = () => resolve(element)
    element.onerror = () => reject(new Error("The chart could not be drawn as an image."))
    element.src = url
  })

  const canvas = document.createElement("canvas")
  canvas.width = width * scale
  canvas.height = height * scale
  const context = canvas.getContext("2d")
  if (!context) throw new Error("This browser has no 2d canvas to draw the chart into.")
  context.scale(scale, scale)
  context.fillStyle = background
  context.fillRect(0, 0, width, height)
  context.drawImage(image, 0, 0, width, height)

  return new Promise<Blob>((resolve, reject) => {
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error("The chart could not be encoded."))),
      "image/png"
    )
  })
}

/* -------------------------------------------------------------------------- */
/* Print                                                                       */
/* -------------------------------------------------------------------------- */


// The sheet lives in its own file; it is re-exported here because `lib/export`
// is the one module a dashboard reaches for, and the three exports are one API.
export { PRINT_HEADER_SLOT, printHeader, printPage, type PrintHeaderOptions } from "@/lib/print"
lib/print.ts
/**
 * The page on paper: the masthead a printed sheet needs, mounted just before
 * the dialog opens and taken down after. The sheet itself — what is hidden,
 * what the palette becomes, how the grid folds — is themes/print.css. Reached
 * through `lib/export`, which re-exports it beside the CSV and the PNG.
 */

export type PrintHeaderOptions = {
  /** The workspace or product the sheet came from. */
  brand?: string
  title: string
  /** The window the numbers cover, e.g. "Last 30 days". */
  range?: string
  /** When they were collected. */
  asOf?: string
}

export const PRINT_HEADER_SLOT = "print-header"

/**
 * The masthead a printed sheet needs, as real text: which workspace, which page,
 * which window, and when the numbers were read. Screen CSS hides it; the print
 * stylesheet shows it.
 */
export function printHeader(options: PrintHeaderOptions): HTMLElement {
  const header = document.createElement("header")
  header.dataset.slot = PRINT_HEADER_SLOT

  const line = (text: string | undefined, slot: string, tag: "p" | "h1" = "p") => {
    if (!text) return
    const element = document.createElement(tag)
    element.dataset.slot = `${PRINT_HEADER_SLOT}-${slot}`
    element.textContent = text
    header.append(element)
  }

  line(options.brand, "brand")
  line(options.title, "title", "h1")
  line([options.range, options.asOf].filter(Boolean).join(" · ") || undefined, "meta")
  return header
}

/**
 * Prints the page under a real masthead.
 *
 * The header is mounted first and taken down once the dialog closes, so nothing
 * is left in the document after the sheet is made. `afterprint` does not fire in
 * every browser, so the removal is also queued behind the print call itself.
 */
export function printPage(options: PrintHeaderOptions): boolean {
  if (typeof document === "undefined" || typeof window.print !== "function") return false
  const header = printHeader(options)
  document.body.prepend(header)

  const cleanUp = () => {
    header.remove()
    window.removeEventListener("afterprint", cleanUp)
  }
  window.addEventListener("afterprint", cleanUp)

  try {
    window.print()
  } finally {
    setTimeout(cleanUp, 0)
  }
  return true
}