Skip to content

File Upload

Receipt/document dropzone: dashed bordered area with upload icon, click-or-drag, file list with size, per-file size validation errors and remove buttons.

neo-corporate/ui/file-upload
Open ↗

Source

"use client";

import { useId, useRef, useState, type DragEvent } from "react";
import { FileText, UploadCloud, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface FileUploadProps {
  label?: string;
  accept?: string;
  /** Max size per file in MB. */
  maxSizeMb?: number;
  onFilesChange?: (files: File[]) => void;
  className?: string;
}

interface Item {
  name: string;
  size: number;
  error?: string;
}

/** Receipt/document dropzone: dashed bordered area with upload icon, click-or-drag, file list with size, per-file size validation errors and remove buttons. */
export function FileUpload({ label = "Attach receipts", accept = ".pdf,.png,.jpg,.csv", maxSizeMb = 10, onFilesChange, className }: FileUploadProps) {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const [over, setOver] = useState(false);
  const [items, setItems] = useState<Item[]>([
    { name: "aws-invoice-sept.pdf", size: 184_000 },
    { name: "team-offsite-hotel.jpg", size: 14_600_000, error: `Exceeds ${10} MB` },
  ]);
  const add = (files: FileList | null) => {
    if (!files) return;
    const list = Array.from(files);
    setItems((prev) => [
      ...prev,
      ...list.map((f) => ({ name: f.name, size: f.size, error: f.size > maxSizeMb * 1e6 ? `Exceeds ${maxSizeMb} MB` : undefined })),
    ]);
    onFilesChange?.(list.filter((f) => f.size <= maxSizeMb * 1e6));
  };
  const drop = (e: DragEvent) => {
    e.preventDefault();
    setOver(false);
    add(e.dataTransfer.files);
  };
  const kb = (n: number) => (n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`);
  return (
    <div className={cn("grid max-w-md gap-2", className)}>
      <span id={`${id}-l`} className="text-sm font-medium">
        {label}
      </span>
      <div
        onDragOver={(e) => {
          e.preventDefault();
          setOver(true);
        }}
        onDragLeave={() => setOver(false)}
        onDrop={drop}
        className={cn(
          "da-transition grid place-items-center rounded-da-lg border-2 border-dashed px-6 py-8 text-center",
          over ? "border-da-primary bg-da-primary/5" : "border-da-border-strong bg-da-surface-2",
        )}
      >
        <UploadCloud aria-hidden className="size-8 text-da-muted-fg" />
        <p className="mt-3 text-sm">
          <button
            type="button"
            aria-describedby={`${id}-l ${id}-h`}
            onClick={() => input.current?.click()}
            className="da-focus rounded-da-sm font-semibold text-da-primary hover:underline"
          >
            Click to upload
          </button>{" "}
          or drag and drop
        </p>
        <p id={`${id}-h`} className="mt-1 text-xs text-da-muted-fg">
          PDF, PNG, JPG or CSV · up to {maxSizeMb} MB
        </p>
        <input ref={input} type="file" multiple accept={accept} className="sr-only" tabIndex={-1} aria-hidden onChange={(e) => add(e.target.files)} />
      </div>
      {items.length > 0 && (
        <ul className="grid gap-2" aria-label="Selected files">
          {items.map((f, i) => (
            <li
              key={`${f.name}-${i}`}
              className={cn("da-stroke flex items-center gap-3 rounded-da-md bg-da-surface px-3 py-2 text-sm", f.error && "border-da-danger/50")}
            >
              <FileText aria-hidden className={cn("size-5 shrink-0", f.error ? "text-da-danger" : "text-da-primary")} />
              <span className="min-w-0 flex-1">
                <span className="block truncate font-medium">{f.name}</span>
                <span className={cn("block text-xs", f.error ? "text-da-danger" : "text-da-muted-fg")}>{f.error ?? kb(f.size)}</span>
              </span>
              <button
                type="button"
                aria-label={`Remove ${f.name}`}
                onClick={() => setItems((p) => p.filter((_, j) => j !== i))}
                className="da-focus grid size-7 place-items-center rounded-da-sm text-da-muted-fg hover:bg-da-muted hover:text-da-fg"
              >
                <X aria-hidden className="size-4" />
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default FileUpload;

modules/neo-corporate/ui/file-upload/index.tsx

Props

PropTypeDefaultDescription
labelstring—Label.
acceptstring—Accept.
maxSizeMbnumber—Max size per file in MB.
onFilesChange(files: File[]) => void—Callback.
classNamestring—Extra classes on the root.

Other input variants in Neo Corporate

Input in other art directions

Input

Labelled text field with hint, error state, leading icon, suffix slot and two sizes. The field presses into its shadow on focus; errors switch to a red stroke with an explicit message.

BrutalistInput

Number Stepper

Numeric input flanked by chunky yellow − / + buttons (disabled at bounds), big display-font value, optional unit suffix; clamps typed values to min/max.

BrutalistInput

OTP Input

One-time-code input: ruled digit squares (split 3+3 for six digits) that turn yellow and press in on focus; auto-advance, backspace and arrow navigation, paste of the full code, completion callback and error state.

BrutalistInput

Textarea

Labelled multi-line field with a live character counter chip that turns orange near the limit, hint and error states; presses into its shadow on focus.

BrutalistInput

Combobox

Autocomplete select: a glass pill input that filters a frosted listbox as you type, with arrow-key highlight, Enter to choose, Escape to close, hints and a check on the selection.

GlassInput

Input

Frosted text field with label, hint, error state, leading icon, trailing slot (button, kbd, unit), rounded or pill shape and two sizes. Focus brightens the glass and adds a soft violet halo.

GlassInput

Range Slider

Glass slider on Radix Slider: frosted track, primary→accent range, glowing gradient thumbs with value bubbles on hover/focus and tick labels; single value or range.

GlassInput

Tag Input

Glass chip input: type and press Enter or comma to add gradient chips, Backspace removes the last, × removes one; quick-add suggestion chips and a max counter.

GlassInput

Input

Hairline text field with label (auto "Optional" marker), hint, error with icon, leading icon, text prefix, trailing slot (unit, kbd) and indigo focus ring. Two sizes.

MinimalInput

Search Input

Search field with leading icon, “/” keycap hint that focuses the field, clear button (Escape clears too) and a loading spinner state. Two sizes, controlled or uncontrolled.

MinimalInput

Select Menu

Labelled select built on Radix Select: bordered trigger with chevrons, portalled popover with optional icons, descriptions, groups, separators, disabled items and a check on the selected one.

MinimalInput

Switch Field

Settings row with label and description on the left and a Radix Switch on the right; plain (for divided lists) or bordered card that highlights when on. Disabled state.

MinimalInput

Checkbox Group

Settings checkboxes as ruled rows: square ink check boxes (native inputs), label and muted description; the whole row is clickable.

Mono CleanInput

Input

Underline field: mono uppercase label, a borderless input on a single bottom hairline that turns ink (2px) on focus, optional prefix, hint or red error.

Mono CleanInput

Search Input

Search field with live results: hairline-bordered input with icon and ⌘K hint; matches appear in a ruled list below with the query highlighted and a mono kind label; empty state included.

Mono CleanInput

Select Field

Native select styled as an underline field with a mono label and a custom chevron — reliable on every device and screen reader.

Mono CleanInput

Chip Select

Multi-select as soft pill checkboxes: unselected sand pills, selected sage pills with a check that pops in; native checkboxes in a fieldset.

Organic SoftInput

Input

Soft filled text field: rounded sand-filled input that turns cream with a sage ring on focus, optional leading icon and trailing unit, hint or gentle clay error message.

Organic SoftInput

Target Slider

Reduction target picker: a thick rounded sage range (0–60%) with tick labels and a “science-aligned” marker, showing the resulting tonnes and a gentle verdict.

Organic SoftInput

Textarea

Soft note field: rounded sand textarea that auto-grows (field-sizing), with a live character counter that turns clay near the limit.

Organic SoftInput

Checkbox List

Round-checkbox to-do list built on native checkboxes: bouncy mint ticks, strike-through when done, meta line per item and an “x of y” counter with a progress bar.

Soft FlatInput

Day Picker

Week-strip date picker: prev/next week arrows and seven rounded day pills (weekday + number) with task-load dots (red when full); the selected day fills periwinkle.

Soft FlatInput

Input

Soft text field: rounded tinted well that turns white with a thick periwinkle ring on focus, label, optional leading icon and suffix, hint or red error message.

Soft FlatInput

Radio Cards

Native radio group rendered as big pastel cards (emoji, title, text); the chosen card lifts with a periwinkle ring and a filled radio dot.

Soft FlatInput