Export Menu New
A dark pill button that opens a light popover of export formats, with icon tiles, shortcuts, simulated progress per row and a privacy note.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/design-export-menu.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/design/ExportMenu.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Check, ChevronDown, Code2, Download, FileCode2, FileImage, Image as ImageIcon, Lock, Share2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { LightFrame, focusRing } from './shared';
export type ExportFormat = { id: string; label: string; hint: string; icon: ReactNode; kbd?: string };
const DEFAULT_FORMATS: ExportFormat[] = [
{ id: 'svg', label: 'SVG', hint: 'Vector, editable paths', icon: <FileCode2 className="h-4 w-4" />, kbd: '⌘⇧S' },
{ id: 'png', label: 'PNG @2x', hint: 'Transparent, 2400 × 1600', icon: <ImageIcon className="h-4 w-4" /> },
{ id: 'pdf', label: 'PDF', hint: 'Print ready, CMYK', icon: <FileImage className="h-4 w-4" /> },
{ id: 'react', label: 'React component', hint: 'Copies TSX to clipboard', icon: <Code2 className="h-4 w-4" />, kbd: '⌘⇧C' },
];
/** A dark pill trigger that opens a light popover of export targets, with simulated progress per row. */
export function ExportMenu({ formats = DEFAULT_FORMATS, label = 'Export' }: { formats?: ExportFormat[]; label?: string }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [done, setDone] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const items = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
if (!open) return;
const close = (e: PointerEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
window.addEventListener('pointerdown', close);
requestAnimationFrame(() => items.current[0]?.focus());
return () => window.removeEventListener('pointerdown', close);
}, [open]);
useEffect(() => {
if (!busy) return;
let p = 0;
const t = setInterval(() => {
p = Math.min(100, p + 7 + Math.random() * 12);
setProgress(p);
if (p >= 100) {
clearInterval(t);
setDone(busy);
setBusy(null);
setTimeout(() => setDone(null), 1600);
}
}, 90);
return () => clearInterval(t);
}, [busy]);
return (
<div ref={ref} className="relative inline-block" onKeyDown={(e) => e.key === 'Escape' && setOpen(false)}>
<button
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
className={cn('flex h-9 items-center gap-2 rounded-full bg-zinc-900 pl-4 pr-3 text-[13px] font-medium text-white shadow-[0_1px_0_rgba(255,255,255,0.15)_inset,0_6px_16px_-6px_rgba(0,0,0,0.5)] transition-colors hover:bg-zinc-800', focusRing)}
>
{busy ? `Exporting ${Math.round(progress)}%` : done ? 'Saved' : label}
{done ? <Check className="h-3.5 w-3.5" /> : <ChevronDown className={cn('h-3.5 w-3.5 opacity-70 transition-transform', open && 'rotate-180')} />}
</button>
{open && (
<div
role="menu"
onKeyDown={(e) => {
const i = items.current.findIndex((el) => el === document.activeElement);
if (e.key === 'ArrowDown') (e.preventDefault(), items.current[(i + 1) % formats.length]?.focus());
if (e.key === 'ArrowUp') (e.preventDefault(), items.current[(i - 1 + formats.length) % formats.length]?.focus());
}}
className="absolute right-0 top-[calc(100%+8px)] z-30 w-[min(300px,calc(100vw-48px))] rounded-2xl border border-zinc-200 bg-white p-1.5 shadow-[0_24px_48px_-16px_rgba(0,0,0,0.25)] animate-scale-in"
style={{ transformOrigin: 'top right' }}
>
<p className="px-2.5 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-zinc-400">Export selection</p>
{formats.map((f, i) => {
const running = busy === f.id;
return (
<button
key={f.id}
ref={(el) => (items.current[i] = el)}
role="menuitem"
disabled={!!busy}
onClick={() => (setProgress(0), setBusy(f.id))}
className="relative flex w-full items-center gap-3 overflow-hidden rounded-xl px-2 py-2 text-left outline-none transition-colors hover:bg-zinc-100 focus-visible:bg-zinc-100 disabled:cursor-progress"
>
{running && <span className="absolute inset-y-0 left-0 bg-zinc-100 transition-[width] duration-100" style={{ width: `${progress}%` }} />}
<span className="relative grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-zinc-100 text-zinc-700 ring-1 ring-inset ring-zinc-200">{f.icon}</span>
<span className="relative min-w-0 flex-1">
<span className="block text-[13px] font-medium text-zinc-900">{f.label}</span>
<span className="block truncate text-[12px] text-zinc-500">{running ? `${Math.round(progress)}% · preparing file` : f.hint}</span>
</span>
<span className="relative shrink-0">
{done === f.id ? (
<Check className="h-4 w-4 text-emerald-600" />
) : f.kbd ? (
<kbd className="rounded-md bg-zinc-100 px-1.5 py-0.5 font-sans text-[11px] text-zinc-500 ring-1 ring-inset ring-zinc-200">{f.kbd}</kbd>
) : (
<Download className="h-3.5 w-3.5 text-zinc-400" />
)}
</span>
</button>
);
})}
<div className="mt-1 flex items-center gap-2 border-t border-zinc-100 px-2.5 pb-1 pt-2.5 text-[11.5px] text-zinc-500">
<Lock className="h-3 w-3 shrink-0" /> Files are made on your device. Nothing is uploaded.
</div>
</div>
)}
</div>
);
}
export function ExportMenuDemo() {
return (
<LightFrame className="mx-auto max-w-[460px]">
<div className="flex min-h-[340px] flex-col">
<div className="flex items-center justify-between gap-2 rounded-2xl border border-zinc-200/80 bg-white px-3 py-2 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-zinc-900">Poster draft</p>
<p className="text-[12px] text-zinc-500">Saved just now</p>
</div>
<div className="flex items-center gap-1.5">
<button aria-label="Share" className={cn('grid h-9 w-9 place-items-center rounded-full text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900', focusRing)}>
<Share2 className="h-4 w-4" />
</button>
<ExportMenu />
</div>
</div>
<p className="mt-auto pt-6 text-center text-[12px] text-zinc-500">Open the menu with Enter. ↑ ↓ to move, Esc to close.</p>
</div>
</LightFrame>
);
}components/niceui/design/shared.tsx
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** A light surface for demos, since the doc stage itself is dark. */
export function LightFrame({ children, className, bare }: { children: ReactNode; className?: string; bare?: boolean }) {
return (
<div className={cn('w-full rounded-2xl font-sans', bare ? 'overflow-hidden bg-[#f4f4f5]' : 'bg-[#fafafa] p-4 sm:p-6', ' text-[13px] text-zinc-800 shadow-[0_1px_0_rgba(255,255,255,0.8)_inset,0_30px_60px_-30px_rgba(0,0,0,0.5)] ring-1 ring-zinc-200', className)}>
{children}
</div>
);
}
export function Panel({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('rounded-2xl border border-zinc-200/80 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]', className)}>{children}</div>;
}
export function SectionHead({ title, meta }: { title: string; meta?: string }) {
return (
<div className="mb-3 flex items-baseline justify-between gap-3">
<h4 className="text-[13px] font-semibold text-zinc-900">{title}</h4>
{meta && <span className="truncate text-[12px] text-zinc-500">{meta}</span>}
</div>
);
}
export const focusRing = 'outline-none focus-visible:ring-2 focus-visible:ring-zinc-900/20 focus-visible:ring-offset-1';