nordaun
blockfilesource: ok · hit
File
A context-based file uploader with customizable titles, descriptions and icons and a built-in workflow.
npx shadcn add "https://ui.nordaun.com/r/file.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
alertbuttonprogress
Files3 · source cached 10/14/2026, 12:13:06 PM
src/components/ui/file.tsx
"use client";
import * as React from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useFiles as useFileContext } from "@/hooks/use-file";
import { messages, Mime, workflow } from "@/lib/file";
import { cn } from "@/lib/utils";
import { X } from "lucide-react";
const contextMap = new Map<string, React.Context<FileContext>>();
export function bindContext(providerId: string): React.Context<FileContext> {
if (!contextMap.has(providerId))
contextMap.set(
providerId,
React.createContext<FileContext>(DefaultFileContext),
);
return contextMap.get(providerId)!;
}
type Metadata = Pick<File, "name" | "size" | "type">;
type FileContext = {
providerId: string;
maxFiles: number;
maxSize: number;
accept: Mime[];
files: File[];
metadatas: Metadata[];
error: string | null;
progress: number | null;
addFiles: (files: FileList | null) => void;
removeFile: (index: number) => void;
clearFiles: () => void;
};
type FileProviderProps = {
providerId: string;
maxFiles: number;
maxSize: number;
accept: Mime[];
children: React.ReactNode;
};
const DefaultFileContext: FileContext = {
providerId: "",
maxFiles: 1, // 1 File
maxSize: 1024 * 1024, // 1 MB
accept: [],
files: [],
metadatas: [],
error: null,
progress: null,
addFiles: () => {},
removeFile: () => {},
clearFiles: () => {},
};
function getFileSize(bytes: number) {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(size % 1 === 0 ? 0 : 2)} ${units[i]}`;
}
function FileProvider({
providerId,
maxFiles,
maxSize,
accept,
children,
}: FileProviderProps) {
const [files, setFiles] = React.useState<File[]>([]);
const [metadatas, setMetadatas] = React.useState<Metadata[]>([]);
const [error, setError] = React.useState<string | null>(null);
const [progress, setProgress] = React.useState<number | null>(null);
const addFiles = (fileList: FileList | null) => {
setError(null);
if (!fileList || fileList.length === 0)
return setError(messages["noFiles"]);
const newFiles: File[] = Array.from(fileList);
if (files.length + newFiles.length > maxFiles)
return setError(messages["tooManyFiles"]);
setProgress(0);
try {
for (const file of newFiles) {
const result = workflow({ file, maxSize, accept });
if (!result.success) return setError(result.message);
const metadata: Metadata = {
name: file.name,
size: file.size,
type: file.type,
};
setFiles((prev) => [...prev, file]);
setMetadatas((prev) => [...prev, metadata]);
setProgress(
(prev) => Number(prev) + Number((100 / newFiles.length).toFixed(0)),
);
}
} catch (error) {
setError(messages["unexpected"]);
console.error("An error occured while validating files", error);
} finally {
setProgress(null);
}
};
const removeFile = (index: number) => {
if (index >= 0 && index < files.length) {
setFiles((prev) => prev.filter((_, i) => i !== index));
setMetadatas((prev) => prev.filter((_, i) => i !== index));
setError(null);
}
};
const clearFiles = () => {
setFiles([]);
setMetadatas([]);
setError(null);
};
const values: FileContext = {
providerId,
maxFiles,
maxSize,
accept,
files,
metadatas,
error,
progress,
addFiles,
removeFile,
clearFiles,
};
const Context = bindContext(providerId);
return <Context.Provider value={values}>{children}</Context.Provider>;
}
const preventDefault = (e: React.DragEvent) => e.preventDefault();
const FileInputContext = React.createContext<string | null>(null);
const useFiles = () => {
const id = React.useContext(FileInputContext);
if (!id) throw new Error("useFiles should be in a file provider");
return useFileContext(id);
};
function FileInput({
providerId,
className,
children,
...props
}: React.ComponentProps<"form"> & {
providerId: string;
}) {
return (
<FileInputContext.Provider value={providerId}>
<form data-slot="file-input" className={className} {...props}>
{children}
</form>
</FileInputContext.Provider>
);
}
function FileField({
className,
children,
...props
}: React.ComponentProps<"input">) {
const { providerId, metadatas, maxFiles, accept, addFiles } = useFiles();
const handleFileAdd = (event: React.ChangeEvent<HTMLInputElement>) => {
addFiles(event.target.files);
event.target.value = "";
};
const handleFileDrop = (event: React.DragEvent<HTMLInputElement>) => {
preventDefault(event);
addFiles(event.dataTransfer.files);
};
const handleClick = () => {
document.getElementById(`file-input-${providerId}`)?.click();
};
return (
<div hidden={metadatas.length > 0}>
<input
id={`file-input-${providerId}`}
type="file"
name={metadatas.length === 0 ? "files" : undefined}
hidden
accept={accept.join(", ")}
multiple={maxFiles > 1}
onChange={handleFileAdd}
onDrop={handleFileDrop}
{...props}
/>
{metadatas.length > 0 && (
<input
type="hidden"
name="files"
value={JSON.stringify(metadatas)}
readOnly
/>
)}
<div
data-slot="file-input-field"
className={cn(
"flex flex-col justify-center items-center gap-4 bg-card text-card-foreground border border-dashed border-border min-h-48 max-h-60 rounded-2xl py-6 shadow-sm cursor-pointer",
className,
)}
onClick={handleClick}
onDrop={handleFileDrop}
onDragOver={preventDefault}
onDragEnter={preventDefault}
onDragLeave={preventDefault}
>
{children}
</div>
</div>
);
}
function FileList({ className, ...props }: React.ComponentProps<"div">) {
const { files, removeFile } = useFiles();
return (
<div
hidden={files.length === 0}
data-slot="file-input-list"
className={cn(
"flex flex-col h-fit max-h-60 p-2 rounded-3xl border-border border bg-card",
className,
)}
{...props}
>
<div className="flex flex-col gap-2 overflow-auto w-full h-full rounded-2xl">
{files.map((file, index) => (
<FileItem
key={index}
file={file}
onRemove={() => removeFile(index)}
/>
))}
</div>
</div>
);
}
function FileItem({ file, onRemove }: { file: File; onRemove: () => void }) {
return (
<div
data-slot="file-input-item"
className="flex flex-row justify-between items-center gap-2 p-2 pl-4 bg-muted/75 text-card-foreground w-full rounded-2xl"
>
<div className="flex flex-col">
<span className="truncate">{file.name}</span>
<span className="text-xs text-muted-foreground">
{getFileSize(file.size)}
</span>
</div>
<Button
type="button"
variant="secondary"
size="icon"
className="rounded-full cursor-pointer bg-muted-foreground/15"
onClick={onRemove}
>
<X />
</Button>
</div>
);
}
function FileError({
className,
children,
...props
}: React.ComponentProps<"div">) {
const { error } = useFiles();
return (
<Alert hidden={!error} className="mt-2">
<AlertDescription
data-slot="file-input-error"
className={cn("leading-none text-sm text-destructive", className)}
{...props}
>
{error ? error : children}
</AlertDescription>
</Alert>
);
}
function FileSubmit({ className, ...props }: React.ComponentProps<"button">) {
const { progress, metadatas } = useFiles();
return (
<Button
type="submit"
variant="default"
className={cn("mt-4 w-full", className)}
disabled={Boolean(progress) || metadatas.length === 0}
{...props}
/>
);
}
function FileContent({ className, ...props }: React.ComponentProps<"div">) {
const { progress } = useFiles();
return (
<div
hidden={Boolean(progress)}
data-slot="file-input-content"
className={cn(
"flex flex-col justify-center items-center text-center gap-2 px-6",
className,
)}
{...props}
/>
);
}
function FileIcon({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="file-input-icon"
className={cn("size-8 text-primary", className)}
{...props}
>
{children}
</div>
);
}
function FileTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="file-input-title"
className={cn("leading-none text-lg font-semibold", className)}
{...props}
/>
);
}
function FileDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="file-input-description"
className={cn("leading-none text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function FileLoader({
className,
children,
...props
}: React.ComponentProps<"div">) {
const { progress } = useFiles();
if (!progress) return null;
return (
<div
data-slot="file-input-loader"
{...props}
className="flex flex-col gap-4 min-w-56 text-center items-center justify-center"
>
<div className={cn("leading-none text-lg font-semibold", className)}>
{children}
</div>
<div>{progress}%</div>
<Progress value={progress} />
</div>
);
}
export {
FileContent,
FileDescription,
FileError,
FileField,
FileIcon,
FileInput,
FileList,
FileLoader,
FileProvider,
FileSubmit,
FileTitle,
};
src/hooks/use-file.ts
import { bindContext } from "@/components/ui/file";
import { useContext } from "react";
export function useFiles(providerId: string) {
const Context = bindContext(providerId);
const context = useContext(Context);
if (!context)
throw new Error(
`The useFiles() hook must be used within the boundaries of a FileProvider with id: "${providerId}".`,
);
return context;
}
src/lib/file.ts
const Mimes = {
application: [
"pdf",
"msword",
"vnd.openxmlformats-officedocument.wordprocessingml.document",
"vnd.ms-excel",
"vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"vnd.ms-powerpoint",
"vnd.openxmlformats-officedocument.presentationml.presentation",
],
video: ["webm", "mp4", "mpeg", "ogg", "quicktime"],
image: ["webp", "jpeg", "png", "gif", "heic", "heif"],
text: ["plain", "csv", "xml"],
} as const satisfies Record<string, string[]>;
type MimeCategory = keyof typeof Mimes;
type Mime =
| {
[category in MimeCategory]: `${category}/${(typeof Mimes)[category][number]}`;
}[MimeCategory]
| (string & {});
const messages = {
sizeLarge: "Your file is too large.",
typeInvalid: "Your file has an invalid type.",
tooManyFiles: "You are uploading too many files.",
noFiles: "You didn't upload any files.",
unexpected: "An unexpected error occured.",
};
type WorkflowProps = {
file: File;
maxSize: number;
accept: Mime[];
};
type WorkflowResult =
| {
success: true;
file: File;
}
| {
success: false;
message: string;
};
/**
* This workflow executes everytime a user adds a new file to your input (feel free to add other params you need)
* @param file The file that the user uploaded
* @param maxSize The max size you allowed in the file context provider
* @param accept The mimes the user can upload you allowed in the file context provider
*/
function workflow({ file, maxSize, accept }: WorkflowProps): WorkflowResult {
// Add your validation, compression or any other file logic here:
if (file.size > maxSize)
return { success: false, message: messages["sizeLarge"] };
if (!accept.includes(file.type))
return { success: false, message: messages["typeInvalid"] };
return {
success: true,
file: file,
};
}
export { messages, Mimes, workflow, type Mime };