- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
The progress is simulated; connect onUploadComplete to your own file handling. It does not upload files to a server.
Installation
$ pnpm dlx shadcn@latest add @cobalt/file-upload
Source
"use client";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import {
RiFileImageLine,
RiFileExcel2Line,
RiFileTextLine,
RiUploadCloudLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
const DEFAULT_EXTENSIONS = ["pdf", "jpg", "jpeg", "png", "xlsx"];
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
type Phase = "idle" | "uploading" | "complete";
export interface FileUploadProps {
onUploadComplete?: (file: File) => void;
allowedExtensions?: readonly string[];
maxBytes?: number;
renderFileIcon?: (file: File) => React.ReactNode;
className?: string;
}
export function formatFileSize(bytes: number) {
if (bytes >= 1024 * 1024) {
const mb = bytes / (1024 * 1024);
return `${mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10} MB`;
}
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
function ringPath(width: number, height: number) {
const r = 15,
x = width - 1,
y = height - 1;
return `M ${width / 2} 1 H ${x - r} A ${r} ${r} 0 0 1 ${x} ${1 + r} V ${y - r} A ${r} ${r} 0 0 1 ${x - r} ${y} H ${1 + r} A ${r} ${r} 0 0 1 1 ${y - r} V ${1 + r} A ${r} ${r} 0 0 1 ${1 + r} 1 Z`;
}
const reveal =
"transition-[opacity,translate,filter] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";
export function FileUpload({
onUploadComplete,
allowedExtensions = DEFAULT_EXTENSIONS,
maxBytes = DEFAULT_MAX_BYTES,
renderFileIcon,
className,
}: FileUploadProps) {
const [phase, setPhase] = React.useState<Phase>("idle");
const [progress, setProgress] = React.useState(0);
const [file, setFile] = React.useState<File | null>(null);
const [rejection, setRejection] = React.useState<string | null>(null);
const [dragOver, setDragOver] = React.useState(false);
const [box, setBox] = React.useState({ width: 533, height: 164 });
const inputRef = React.useRef<HTMLInputElement>(null);
const boxRef = React.useRef<HTMLButtonElement>(null);
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
React.useEffect(() => {
const element = boxRef.current;
if (!element) {
return;
}
const observer = new ResizeObserver(([entry]) => {
if (entry.contentRect.width > 0) {
setBox({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
observer.observe(element);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
const pending = timers.current;
return () => {
for (const timer of pending) {
clearTimeout(timer);
}
};
}, []);
function startUpload(next: File) {
const extension = next.name.split(".").pop()?.toLowerCase() ?? "";
if (!allowedExtensions.some((value) => value.toLowerCase() === extension)) {
setRejection(
`Choose a ${allowedExtensions.map((value) => value.toUpperCase()).join(", ")} file.`
);
timers.current.push(setTimeout(() => setRejection(null), 2600));
return;
}
if (next.size > maxBytes) {
setRejection(`Choose a file under ${formatFileSize(maxBytes)}.`);
timers.current.push(setTimeout(() => setRejection(null), 2600));
return;
}
setRejection(null);
setFile(next);
setProgress(0);
setPhase("uploading");
let value = 0;
const tick = () => {
value = Math.min(100, value + 2 + Math.random() * 5);
setProgress(Math.round(value));
if (value < 100) {
timers.current.push(setTimeout(tick, 90));
return;
}
setPhase("complete");
timers.current.push(
setTimeout(() => {
onUploadComplete?.(next);
setPhase("idle");
setFile(null);
}, 1600)
);
};
timers.current.push(setTimeout(tick, 250));
}
const busy = phase !== "idle";
const extension = file?.name.split(".").pop()?.toLowerCase();
const Icon =
extension === "xlsx"
? RiFileExcel2Line
: ["jpg", "jpeg", "png"].includes(extension ?? "")
? RiFileImageLine
: RiFileTextLine;
const line = (visible: boolean, delay: string) =>
cn(
reveal,
visible
? cn("translate-y-0 opacity-100 blur-none", delay)
: "translate-y-0 opacity-0 blur-none !duration-200"
);
return (
<div data-slot="file-upload" className={cn("relative w-full", className)}>
<input
ref={inputRef}
type="file"
aria-label="Choose a file"
accept={allowedExtensions.map((value) => `.${value}`).join(",")}
className="sr-only"
tabIndex={-1}
onChange={(event) => {
const next = event.target.files?.[0];
event.target.value = "";
if (next) {
startUpload(next);
}
}}
/>
<ButtonPrimitive
ref={boxRef}
type="button"
aria-label="Choose or drop a file"
aria-disabled={busy}
onClick={() => {
if (!busy) {
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
if (!busy) {
setDragOver(true);
}
}}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
setDragOver(false);
const [next] = event.dataTransfer.files;
if (next && !busy) {
startUpload(next);
}
}}
className={cn(
"group relative h-[164px] w-full shrink-0 rounded-[16px] text-left outline-none transition-colors duration-300 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
busy
? "bg-[var(--surface-raised,var(--background))]"
: "cursor-pointer bg-[var(--surface-subtle,var(--muted))]"
)}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-[16px] border-2 border-dashed border-[var(--border-strong,var(--border))] transition-[opacity,border-color] duration-300 ease-out group-hover:border-[var(--foreground-subtle,var(--ring))] motion-reduce:transition-none",
dragOver && "border-[var(--foreground-subtle,var(--ring))]",
busy ? "opacity-0" : "opacity-100"
)}
/>
<svg
aria-hidden
viewBox={`0 0 ${box.width} ${box.height}`}
preserveAspectRatio="none"
className={cn(
"pointer-events-none absolute inset-0 size-full transition-opacity duration-300 ease-out motion-reduce:transition-none",
busy ? "opacity-100" : "opacity-0"
)}
>
<rect
x={1}
y={1}
width={box.width - 2}
height={box.height - 2}
rx={15}
fill="none"
stroke="var(--border)"
strokeWidth={2}
/>
<path
d={ringPath(box.width, box.height)}
fill="none"
stroke="var(--file-upload-progress, var(--primary))"
strokeWidth={2}
strokeLinecap="round"
pathLength={100}
strokeDasharray={`${busy ? (progress >= 100 ? 102 : progress) : 0} 200`}
className="transition-[stroke-dasharray] duration-200 ease-linear motion-reduce:transition-none"
/>
</svg>
<span
aria-hidden
className={cn(
"absolute -top-[9.5px] left-1/2 -translate-x-1/2 rounded-[6px] bg-[var(--file-upload-progress-label,var(--primary))] px-1.5 py-0.5 text-xs leading-4 font-medium tracking-[0.15px] whitespace-nowrap text-primary-foreground tabular-nums [transition:opacity_160ms_ease-out,filter_160ms_ease-out,translate_300ms_ease-out] motion-reduce:transition-none",
busy
? "translate-y-0 opacity-100 blur-none"
: "-translate-y-2.5 opacity-0 blur-[2px]"
)}
>
{progress}%
</span>
<span
aria-hidden={busy}
className={cn(
"absolute inset-0 flex flex-col items-center justify-center gap-3.5",
busy && "pointer-events-none"
)}
>
<span
className={cn(
"flex size-10 items-center justify-center rounded-full bg-[var(--foreground-disabled,var(--muted))] p-2.5",
line(!busy, "delay-0")
)}
>
<RiUploadCloudLine
className="size-6 shrink-0 text-[color:var(--file-upload-icon,var(--muted-foreground))] transition-colors duration-150 ease-[ease] group-hover:text-[color:var(--file-upload-icon-hover,var(--foreground))] motion-reduce:transition-none"
aria-hidden
/>
</span>
<span className="flex flex-col items-center gap-2 text-center">
<span
className={cn(
"text-sm leading-5 font-medium text-[color:var(--file-upload-text,var(--muted-foreground))]",
rejection && "text-destructive",
line(!busy, "delay-40")
)}
>
{rejection ?? (
<>
Drop a file here or{" "}
<span className="text-[color:var(--file-upload-link,var(--primary))]">
browse
</span>
</>
)}
</span>
<span
className={cn(
"text-[13px] leading-[18px] text-[color:var(--file-upload-text,var(--muted-foreground))]",
line(!busy, "delay-[80ms]")
)}
>
{allowedExtensions
.filter((value) => value.toLowerCase() !== "jpeg")
.map((value) => value.toUpperCase())
.join(", ")}{" "}
(max {formatFileSize(maxBytes)})
</span>
</span>
</span>
<span
aria-hidden={!busy}
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
!busy && "pointer-events-none"
)}
>
<span
className={cn(
"flex size-10 items-center justify-center rounded-full border border-border bg-[var(--surface-raised,var(--background))] p-2",
line(busy, "delay-0")
)}
>
{file &&
(renderFileIcon ? (
renderFileIcon(file)
) : (
<Icon
className="size-6 shrink-0 text-[color:var(--file-upload-text,var(--muted-foreground))]"
aria-hidden
/>
))}
</span>
<span
className={cn(
"mt-3.5 max-w-[90%] truncate text-sm leading-5 font-medium text-foreground",
line(busy, "delay-40")
)}
>
{file?.name}
</span>
<span
className={cn(
"mt-1 h-[18px] text-[13px] leading-[18px] text-[color:var(--file-upload-text,var(--muted-foreground))]",
line(busy, "delay-[80ms]")
)}
>
{phase === "complete"
? "File ready"
: `Preparing ${file ? formatFileSize(file.size) : ""}…`}
</span>
</span>
</ButtonPrimitive>
<span role="status" className="sr-only">
{rejection ??
(busy
? `${phase === "complete" ? "File ready" : "Preparing file"}, ${progress}%`
: "")}
</span>
</div>
);
} On this page