Components

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

Installation#

Same package as the rest of Foren.

bash
npm install @foren/ui
tsx
import { ForenFileUpload, ForenFileItem } from "@foren/ui";

Usage#

Handle dropped/selected files, then render each as a File Item.

tsx
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.

Choose file…
brand-guidelines.pdf
Choose file…

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

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.

Source#

Drop-in source if you'd rather skip the package.

components/ui/foren-file-upload.tsx
"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>
    );
  }
);
components/ui/foren-file-item.tsx
"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.

tsx
// 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