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-menuNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ExportMenu, type ExportFormat } from "@/components/ui/export-menu"
export default function ExportMenuDemo() {
const [lastExport, setLastExport] = React.useState<ExportFormat | null>(null)
async function handleExport(format: ExportFormat) {
// Stands in for the request that builds the file.
await new Promise((resolve) => setTimeout(resolve, 1200))
setLastExport(format)
}
return (
<div className="w-full max-w-md rounded-lg border">
<div className="flex items-center justify-between gap-4 border-b px-3 py-2">
<div className="flex flex-col">
<span className="text-sm font-medium">Requests</span>
<span className="text-xs text-muted-foreground">Last 7 days · 42,918 rows</span>
</div>
<ExportMenu formats={["csv", "json", "xlsx"]} onExport={handleExport} size="sm" />
</div>
<p className="px-3 py-2 text-xs text-muted-foreground">
{lastExport ? `Downloaded requests.${lastExport}` : "Pick a format to download the table."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 format | Which formats to list, in the order given. |
| label | React.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. |
| variant | Button variant | "outline" | Passed straight to the trigger Button, along with every other Button prop. |
| disabled | boolean | false | Blocks the menu, e.g. while the table underneath has nothing to export. |
Dependencies
Source
"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 }