Skip to contentVibraUI
Utilities

Export menu

A download button that opens the export formats and shows a spinner while one is building.

Return a promise from onExport and the trigger takes over the waiting: it disables itself, swaps the download icon for a spinner, and sets aria-busy until the promise settles. A synchronous handler leaves the trigger alone. The pending state is dropped if the menu unmounts mid-export.

Install

npx shadcn@latest add @vibra/export-menu

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

Examples

Props

PropTypeDefaultDescription
onExport(format: ExportFormat) => void | Promise<void>—Called with the chosen format; return a promise to put the trigger in its pending state.
formats("csv" | "json" | "xlsx" | "pdf" | "png")[]every formatWhich formats to list, in the order given.
labelReact.ReactNode"Export"The trigger's text, between the download icon and the chevron.
align"start" | "end""end"Which edge of the trigger the menu lines up with.
size"sm" | "default""default"sm matches a table toolbar; default matches a page header.
variantButton variant"outline"Passed straight to the trigger Button, along with every other Button prop.
disabledbooleanfalseBlocks the menu, e.g. while the table underneath has nothing to export.

Dependencies

Source

components/ui/export-menu.tsx
"use client"

import * as React from "react"
import {
  BracesIcon,
  ChevronDownIcon,
  DownloadIcon,
  FileSpreadsheetIcon,
  FileTextIcon,
  ImageIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Spinner } from "@/components/ui/spinner"

export type ExportFormat = "csv" | "json" | "xlsx" | "pdf" | "png"

const FORMATS: Record<
  ExportFormat,
  { label: string; icon: React.ComponentType<{ className?: string }> }
> = {
  csv: { label: "CSV", icon: FileSpreadsheetIcon },
  json: { label: "JSON", icon: BracesIcon },
  xlsx: { label: "XLSX", icon: FileSpreadsheetIcon },
  pdf: { label: "PDF", icon: FileTextIcon },
  png: { label: "PNG", icon: ImageIcon },
}

const ALL_FORMATS: ExportFormat[] = ["csv", "json", "xlsx", "pdf", "png"]

export type ExportMenuProps = Omit<React.ComponentProps<typeof Button>, "size" | "children"> & {
  /** Called with the chosen format; return a promise to put the trigger in its pending state until it settles. */
  onExport: (format: ExportFormat) => void | Promise<void>
  formats?: ExportFormat[]
  label?: React.ReactNode
  align?: "start" | "end"
  size?: "sm" | "default"
  disabled?: boolean
}

function ExportMenu({
  className,
  onExport,
  formats = ALL_FORMATS,
  label = "Export",
  align = "end",
  size = "default",
  disabled = false,
  variant = "outline",
  ...props
}: ExportMenuProps) {
  const [pending, setPending] = React.useState<ExportFormat | null>(null)
  // The export can outlive the menu — a row can be filtered away mid-download.
  const alive = React.useRef(true)
  React.useEffect(() => {
    alive.current = true
    return () => {
      alive.current = false
    }
  }, [])

  async function handleExport(format: ExportFormat) {
    const result = onExport(format)
    if (!(result instanceof Promise)) return
    setPending(format)
    try {
      await result
    } finally {
      if (alive.current) setPending(null)
    }
  }

  const busy = pending !== null

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        render={
          <Button
            type="button"
            data-slot="export-menu"
            data-size={size}
            variant={variant}
            size={size === "sm" ? "sm" : "default"}
            disabled={disabled || busy}
            aria-busy={busy || undefined}
            className={cn(className)}
            {...props}
          >
            {busy ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
            {label}
            <ChevronDownIcon data-icon="inline-end" className="text-muted-foreground" />
          </Button>
        }
      />
      <DropdownMenuContent align={align} className="w-40">
        {formats.map((format) => {
          const { label: name, icon: Icon } = FORMATS[format]
          return (
            <DropdownMenuItem
              key={format}
              data-slot="export-menu-item"
              data-format={format}
              onClick={() => void handleExport(format)}
            >
              <Icon className="text-muted-foreground" />
              {name}
            </DropdownMenuItem>
          )
        })}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

export { ExportMenu }