File upload
Drag-and-drop dropzone and compact inline field. The File Item sub-component shows upload progress with a real Progress bar, plus success and error states.
Preview#
Drag & drop files here
or click to browse. Max 10MB per file.
quarterly-report.pdf
2.4 MB · 60%
logo.svg
18 KB
<ForenFileUpload onFiles={handleFiles} multiple />
<ForenFileItem state="uploading" fileName="quarterly-report.pdf" fileSize="2.4 MB" progress={60} onRemove={remove} />
<ForenFileItem state="success" fileName="logo.svg" fileSize="18 KB" onRemove={remove} />Installation#
Same package as the rest of Foren.
npm install @foren/uiimport { ForenFileUpload, ForenFileItem } from "@foren/ui";Usage#
Handle dropped/selected files, then render each as a File Item.
function Uploader() {
const [files, setFiles] = useState<File[]>([]);
return (
<div className="flex flex-col gap-3">
<ForenFileUpload
multiple
accept="image/*,application/pdf"
onFiles={(list) => setFiles((f) => [...f, ...Array.from(list)])}
/>
{files.map((f) => (
<ForenFileItem
key={f.name}
state="ready"
fileName={f.name}
fileSize={formatSize(f.size)}
onRemove={() => setFiles((prev) => prev.filter((x) => x !== f))}
/>
))}
</div>
);
}Upload types#
Dropzone for drag & drop, Compact for inline selection.
Drag & drop files here
or click to browse. Max 10MB per file.
Drop to upload
or click to browse. Max 10MB per file.
File is too large. Max 10MB.
{/* Dropzone (default) */}
<ForenFileUpload variant="dropzone" />
{/* Compact inline field */}
<ForenFileUpload variant="compact" />
{/* Compact, filled */}
<ForenFileUpload variant="compact" fileName="brand-guidelines.pdf" />
{/* With an error */}
<ForenFileUpload variant="compact" error="File is too large. Max 10MB." />File item states#
Ready, uploading (real Progress bar), success, and error.
contract.pdf
1.2 MB
dataset.csv
8.1 MB · 45%
avatar.png
240 KB
video.mov
Upload failed — file too large
<ForenFileItem state="ready" fileName="contract.pdf" fileSize="1.2 MB" onRemove={remove} />
<ForenFileItem state="uploading" fileName="dataset.csv" fileSize="8.1 MB" progress={45} onRemove={remove} />
<ForenFileItem state="success" fileName="avatar.png" fileSize="240 KB" onRemove={remove} />
<ForenFileItem state="error" fileName="video.mov" fileSize="Upload failed — file too large" onRetry={retry} onRemove={remove} />Interactive#
Drop or browse for real files and watch them upload.
Drag & drop files here
or click to browse. Any file, up to a few MB.
// See components/docs/file-upload-previews.tsx for the full demo.
<ForenFileUpload onFiles={addFiles} multiple />
{rows.map((r) => (
<ForenFileItem key={r.id} state={r.state} fileName={r.name}
fileSize={r.size} progress={r.progress} onRemove={() => remove(r.id)} />
))}Source#
Drop-in source if you'd rather skip the package.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ForenButton } from "./foren-button";
import { RiUploadCloud2Line } from "@remixicon/react";
export interface ForenFileUploadProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onError"> {
/** Layout: full drag-and-drop zone, or a compact inline field. */
variant?: "dropzone" | "compact";
/** Dropzone headline. */
title?: string;
/** Dropzone helper line — put the size/format limits here. */
description?: string;
/** Browse button label. */
browseLabel?: string;
/** Compact placeholder shown before a file is chosen. */
placeholder?: string;
/** Selected file name — switches compact/dropzone into the "filled" look. */
fileName?: string;
accept?: string;
multiple?: boolean;
disabled?: boolean;
/** Error message. Renders error styling and shows the text below. */
error?: string;
/** Force the drag-over visual (used by docs; real drag-over is automatic). */
forceDragover?: boolean;
/** Called with the chosen/dropped files. */
onFiles?: (files: FileList) => void;
}
/**
* File upload trigger. `dropzone` is a dashed drag-and-drop area; `compact`
* is an inline field + browse button. Render selected files with `ForenFileItem`.
*/
export const ForenFileUpload = React.forwardRef<HTMLDivElement, ForenFileUploadProps>(
function ForenFileUpload(
{
className,
variant = "dropzone",
title = "Drag & drop files here",
description = "or click to browse. Max 10MB per file.",
browseLabel = "Browse files",
placeholder = "Choose file…",
fileName,
accept,
multiple,
disabled = false,
error,
forceDragover = false,
onFiles,
...props
},
ref
) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [dragging, setDragging] = React.useState(false);
const active = dragging || forceDragover;
const openPicker = () => {
if (!disabled) inputRef.current?.click();
};
const handleFiles = (files: FileList | null) => {
if (files && files.length) onFiles?.(files);
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (disabled) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setDragging(true);
};
const hiddenInput = (
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
);
if (variant === "compact") {
const borderColor = error
? "border-[var(--color-foren-error)]"
: active
? "border-[var(--color-foren-border-strong)]"
: disabled
? "border-[var(--color-foren-border-disabled)]"
: "border-[var(--color-foren-border-muted)]";
return (
<div ref={ref} className={cn("w-full", className)} {...props}>
<div
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={() => setDragging(false)}
className={cn(
"flex items-center gap-3 w-full h-12 pl-4 pr-1.5 rounded-xl border bg-[var(--color-foren-bg-default)] transition-colors",
borderColor,
disabled && "opacity-60"
)}
>
<span
className={cn(
"flex-1 min-w-0 truncate text-[14px] leading-[21px]",
fileName
? "text-[var(--color-foren-text-primary)] font-medium"
: "text-[var(--color-foren-text-tertiary)]"
)}
>
{fileName ?? placeholder}
</span>
<ForenButton size="xs" variant="primary" disabled={disabled} onClick={openPicker}>
{fileName ? "Change" : browseLabel}
</ForenButton>
</div>
{error && (
<p className="mt-1.5 text-[12px] leading-[18px] font-medium text-[var(--color-foren-error)]">
{error}
</p>
)}
{hiddenInput}
</div>
);
}
// dropzone
const borderColor = error
? "border-[var(--color-foren-error)]"
: active
? "border-[var(--color-foren-border-strong)]"
: disabled
? "border-[var(--color-foren-border-disabled)]"
: "border-[var(--color-foren-border-muted)]";
return (
<div ref={ref} className={cn("w-full", className)} {...props}>
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
onClick={openPicker}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPicker();
}
}}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={() => setDragging(false)}
className={cn(
"w-full rounded-xl border-2 border-dashed bg-[var(--color-foren-bg-default)] flex flex-col items-center justify-center gap-3 px-6 py-8 text-center transition-colors outline-none",
"focus-visible:border-[var(--color-foren-border-strong)]",
borderColor,
active && "bg-[var(--color-foren-bg-subtle)]",
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer"
)}
>
<span className="size-12 rounded-full bg-[var(--color-foren-bg-subtle)] flex items-center justify-center text-[var(--color-foren-text-secondary)]">
<RiUploadCloud2Line size={20} />
</span>
<div className="flex flex-col gap-2 items-center">
<p className="text-[14px] leading-[21px] font-semibold tracking-[0.0125em] text-[var(--color-foren-text-primary)]">
{fileName ?? title}
</p>
<p className="text-[12px] leading-[18px] font-medium tracking-[0.01em] text-[var(--color-foren-text-tertiary)]">
{description}
</p>
</div>
<ForenButton size="xs" variant="primary" disabled={disabled} tabIndex={-1}>
{browseLabel}
</ForenButton>
</div>
{error && (
<p className="mt-2 text-[12px] leading-[18px] font-medium text-[var(--color-foren-error)]">
{error}
</p>
)}
{hiddenInput}
</div>
);
}
);
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { ForenProgress } from "./foren-progress";
import {
RiFileLine,
RiFilePdf2Fill,
RiFileImageFill,
RiCloseLine,
RiCheckboxCircleLine,
RiErrorWarningLine,
RiRefreshLine,
type RemixiconComponentType,
} from "@remixicon/react";
// File-type logos from Remix Icon, keyed off the extension. Remix has no
// SVG-specific glyph, so SVG maps to the image-file logo.
const FILE_TYPE_ICON: Record<string, { Icon: RemixiconComponentType; fg: string }> = {
PDF: { Icon: RiFilePdf2Fill, fg: "text-[var(--color-foren-error)]" },
SVG: { Icon: RiFileImageFill, fg: "text-[var(--color-foren-indigo)]" },
};
function extensionOf(fileName: string): string {
const dot = fileName.lastIndexOf(".");
return dot > -1 ? fileName.slice(dot + 1).toUpperCase() : "";
}
function FileTypeIcon({ fileName, override }: { fileName: string; override?: React.ReactNode }) {
const entry = FILE_TYPE_ICON[extensionOf(fileName)];
const content = override ? (
override
) : entry ? (
<entry.Icon size={20} className={entry.fg} />
) : (
<RiFileLine size={20} className="text-[var(--color-foren-text-secondary)]" />
);
return (
<span className="shrink-0 size-9 rounded-xl bg-[var(--color-foren-bg-default)] flex items-center justify-center" aria-hidden>
{content}
</span>
);
}
const itemVariants = cva(
"flex items-center gap-3 p-3 rounded-2xl w-full",
{
variants: {
state: {
ready: "bg-[var(--color-foren-bg-subtle)]",
uploading: "bg-[var(--color-foren-bg-subtle)]",
success: "bg-[var(--color-foren-bg-success-subtle)]",
error: "bg-[var(--color-foren-bg-error-subtle)]",
},
},
defaultVariants: { state: "ready" },
}
);
export interface ForenFileItemProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children">,
VariantProps<typeof itemVariants> {
/** File name shown as the primary label. */
fileName: string;
/** Secondary meta line — size, status text, or error message. */
fileSize?: string;
/** 0–100. Shown as a progress bar when `state="uploading"`. */
progress?: number;
/** Renders a remove (X) button when provided. */
onRemove?: () => void;
/** Renders a retry button in the error state when provided. */
onRetry?: () => void;
/** Custom leading icon (defaults to a file glyph). */
icon?: React.ReactNode;
}
/**
* A single uploaded/uploading file row. Four states — ready, uploading
* (with a real Progress bar), success, error. Pair with `ForenFileUpload`.
*/
export const ForenFileItem = React.forwardRef<HTMLDivElement, ForenFileItemProps>(
function ForenFileItem(
{ className, state = "ready", fileName, fileSize, progress = 0, onRemove, onRetry, icon, ...props },
ref
) {
const metaColor =
state === "success"
? "text-[var(--color-foren-success)]"
: state === "error"
? "text-[var(--color-foren-error)]"
: "text-[var(--color-foren-text-tertiary)]";
return (
<div ref={ref} className={cn(itemVariants({ state }), className)} {...props}>
{/* File-type logo */}
<FileTypeIcon fileName={fileName} override={icon} />
{/* Info */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<p className="text-[12px] leading-[18px] font-medium tracking-[0.01em] text-[var(--color-foren-text-primary)] truncate">
{fileName}
</p>
{state === "uploading" ? (
<>
{fileSize && (
<p className="text-[12px] leading-[18px] font-medium text-[var(--color-foren-text-tertiary)] truncate">
{fileSize}
</p>
)}
<ForenProgress size="sm" value={progress} hideValue className="mt-0.5" />
</>
) : (
fileSize && (
<p className={cn("text-[12px] leading-[18px] font-medium truncate", metaColor)}>
{fileSize}
</p>
)
)}
</div>
{/* Trailing status + actions */}
<div className="shrink-0 flex items-center gap-1.5">
{state === "success" && (
<span className="text-[var(--color-foren-success)]" aria-hidden>
<RiCheckboxCircleLine size={18} />
</span>
)}
{state === "error" && (
<span className="text-[var(--color-foren-error)]" aria-hidden>
<RiErrorWarningLine size={18} />
</span>
)}
{state === "error" && onRetry && (
<button
type="button"
onClick={onRetry}
aria-label={`Retry upload of ${fileName}`}
className="size-5 flex items-center justify-center rounded-[10px] text-[var(--color-foren-text-secondary)] hover:bg-[var(--color-foren-bg-button-secondary-active)] transition-colors"
>
<RiRefreshLine size={14} />
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${fileName}`}
className="size-5 flex items-center justify-center rounded-[10px] text-[var(--color-foren-text-tertiary)] hover:bg-[var(--color-foren-bg-button-secondary-active)] hover:text-[var(--color-foren-text-primary)] transition-colors"
>
<RiCloseLine size={14} />
</button>
)}
</div>
</div>
);
}
);
API#
Props for both components.
// ForenFileUpload
variant?: "dropzone" | "compact" // default "dropzone"
title?: string // dropzone headline
description?: string // dropzone helper line
browseLabel?: string // button label ("Browse files")
placeholder?: string // compact placeholder
fileName?: string // selected file → "filled" look
accept?: string
multiple?: boolean
disabled?: boolean
error?: string // error styling + message
onFiles?: (files: FileList) => void
// ForenFileItem
state?: "ready" | "uploading" | "success" | "error" // default "ready"
fileName: string
fileSize?: string // meta / status / error text
progress?: number // 0–100, shown when uploading
onRemove?: () => void // renders the X button
onRetry?: () => void // renders retry in the error state
icon?: React.ReactNode