Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
MediaUpdatedFreeWCAG 2.2 AA

File Upload

Drag-and-drop file upload.

Preview

Live preview
@oshon-ai/components
Interactive — pick a file to step through the lifecycle

or drag and drop it here.

(files accepted, max file size)

Pick any file under 5 MB. Larger files are rejected by the component's `maxFileSize` validation; the toast above will show the rejection reason. Progress fakes at ~220 ms ticks of +8%. × on the finished chip resets.

Idle — default state

or drag and drop it here.

(files accepted, max file size)

Uploading — 42% progress

quarterly-report.pdf

42%

Finished — file in the chip, remove or upload another
quarterly-report.pdf

or drag and drop it here.

Error — upload failed, retry available

or drag and drop it here.

Disabled

or drag and drop it here.

(files accepted, max file size)

Panel layout — 288 px sidebar variant

or drag and drop it here.

(files accepted, max file size)

Sizes — xs · s · m · l · mobile

or drag and drop it here.

(files accepted, max file size)

or drag and drop it here.

(files accepted, max file size)

or drag and drop it here.

(files accepted, max file size)

or drag and drop it here.

(files accepted, max file size)

or drag and drop it here.

(files accepted, max file size)

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add fileupload

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { FileUpload } from '@oshon-ai/components';

export default function Example() {
  return <FileUpload />;
}

Page — all 5 states

or drag and drop it here.

(files accepted, max file size)

Enabled — idle drop zone · page, size=m (Figma 1056 × 80)

File name.pdf

45%

Uploading — 45% · page, size=m (Figma 1056 × 80)
File name.pdf

or drag and drop it here.

Finished — file chip + upload another · page, size=m (Figma 1056 × 130)

or drag and drop it here.

Error — retry affordance · page, size=m (Figma 1056 × 130)

or drag and drop it here.

(files accepted, max file size)

Disabled — muted, non-interactive · page, size=m (Figma 1056 × 80)
tsx
<div style={col}>
      {STATE_ORDER.map((s) => (
        <div
          key={s.state}
          style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
        >
          <FileUpload
            layout="page"
            state={s.state}
            filename={s.filename}
            progress={s.progress ?? 0}
          />
          <div style={caption}>
            <strong style={captionStrong}>{s.label}</strong> · page, size=m
            (Figma 1056 × {s.state === 'finished' || s.state === 'error' ? 130 : 80})
          </div>
        </div>
      ))}
    </div>

Panel — all 5 states

or drag and drop it here.

(files accepted, max file size)

idle
panel, size=m (Figma 288 × 122)

File name.pdf

45%

uploading
panel, size=m (Figma 288 × 122)
File name.pdf

or drag and drop it here.

finished
panel, size=m (Figma 288 × 122)

or drag and drop it here.

error
panel, size=m (Figma 288 × 122)

or drag and drop it here.

(files accepted, max file size)

disabled
panel, size=m (Figma 288 × 122)
tsx
<div style={col}>
      <div style={{ ...row, gap: '1.5rem' }}>
        {STATE_ORDER.map((s) => (
          <div
            key={s.state}
            style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
          >
            <FileUpload
              layout="panel"
              state={s.state}
              filename={s.filename}
              progress={s.progress ?? 0}
            />
            <div style={caption}>
              <strong style={captionStrong}>{s.state}</strong>
              <br />
              panel, size=m (Figma 288 × 122)
            </div>
          </div>
        ))}
      </div>
    </div>

Size matrix — QA grid (page + panel)

layout = page
size=xs

or drag and drop it here.

(files accepted, max file size)

size=s

or drag and drop it here.

(files accepted, max file size)

size=m

or drag and drop it here.

(files accepted, max file size)

size=l

or drag and drop it here.

(files accepted, max file size)

size=mobile

or drag and drop it here.

(files accepted, max file size)

layout = panel
size=xs

or drag and drop it here.

(files accepted, max file size)

size=s

or drag and drop it here.

(files accepted, max file size)

size=m

or drag and drop it here.

(files accepted, max file size)

size=l

or drag and drop it here.

(files accepted, max file size)

size=mobile

or drag and drop it here.

(files accepted, max file size)

tsx
<div style={col}>
      {LAYOUTS.map((layout) => (
        <div
          key={layout}
          style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
        >
          <div style={caption}>
            <strong style={captionStrong}>layout = {layout}</strong>
          </div>
          <div
            style={{
              display: 'flex',
              flexDirection: layout === 'page' ? 'column' : 'row',
              gap: '0.75rem',
              flexWrap: 'wrap',
            }}
          >
            {SIZES.map((size) => (
              <div
                key={size}
                style={{
                  display: 'flex',
                  flexDirection: layout === 'page' ? 'row' : 'column',
                  alignItems: layout === 'page' ? 'flex-start' : 'stretch',
                  gap: '0.75rem',
                }}
              >
                <span
                  style={{
                    ...caption,
                    minWidth: '72px',
                    paddingTop: layout === 'page' ? '28px' : 0,
                  }}
                >
                  size={size}
                </span>
                <FileUpload layout={layout} size={size} />
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>

Page — working upload demo

Pick a file via the dialog or drop one on the zone. The story simulates a 2-second upload, fires Finished, and gives you Reset / Trigger-error / Retry controls to walk every state transition.
state = idle

or drag and drop it here.

(files accepted, max file size)

tsx
{
    const upload = useSimulatedUpload();
    return (
      <div style={col}>
        <div style={caption}>
          Pick a file via the dialog or drop one on the zone. The story
          simulates a 2-second upload, fires Finished, and gives you Reset
          / Trigger-error / Retry controls to walk every state transition.
        </div>
        <div style={row}>
          <button
            type="button"
            onClick={upload.reset}
            style={toolbarButton}
          >
            Reset
          </button>
          <button
            type="button"
            onClick={upload.triggerError}
            style={toolbarButton}
            disabled={upload.state === 'idle'}
          >
            Simulate error
          </button>
          {upload.state === 'error' ? (
            <button
              type="button"
              onClick={upload.retry}
              style={toolbarButton}
            >
              Retry upload
            </button>
          ) : null}
          <span style={{ ...caption, alignSelf: 'center' }}>
            state = <strong style={captionStrong}>{upload.state}</strong>
            {upload.state === 'uploading' ? ` · ${upload.progress}%` : ''}
            {upload.filename ? ` · ${upload.filename}` : ''}
          </span>
        </div>
        <FileUpload
          layout="page"
          state={upload.state}
          progress={upload.progress}
          filename={upload.filename}
          onFilesSelected={upload.startUpload}
          onRemove={upload.reset}
          onRetry={upload.retry}
        />
      </div>
    );
  }

Panel — working upload demo

Same simulated upload in the panel card. Drop a file or pick one via the dialog.

or drag and drop it here.

(files accepted, max file size)

tsx
{
    const upload = useSimulatedUpload();
    return (
      <div
        style={{ ...col, maxWidth: '360px', alignItems: 'flex-start' }}
      >
        <div style={caption}>
          Same simulated upload in the panel card. Drop a file or pick
          one via the dialog.
        </div>
        <div style={row}>
          <button
            type="button"
            onClick={upload.reset}
            style={toolbarButton}
          >
            Reset
          </button>
          <button
            type="button"
            onClick={upload.triggerError}
            style={toolbarButton}
            disabled={upload.state === 'idle'}
          >
            Simulate error
          </button>
        </div>
        <FileUpload
          layout="panel"
          state={upload.state}
          progress={upload.progress}
          filename={upload.filename}
          onFilesSelected={upload.startUpload}
          onRemove={upload.reset}
          onRetry={upload.retry}
        />
      </div>
    );
  }

CSV / XLSX only — localized

Realistic "import a spreadsheet" flow. `accept=".csv,.xlsx"` + `multiple` on the native input, French messages for every string, custom constraint hint.

ou déposer ici.

(CSV ou XLSX, 10 Mo max)

tsx
<div style={col}>
      <div style={caption}>
        Realistic "import a spreadsheet" flow. `accept=".csv,.xlsx"` +
        `multiple` on the native input, French messages for every
        string, custom constraint hint.
      </div>
      <FileUpload
        layout="page"
        accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
        multiple
        messages={{
          uploadLabel: 'Téléverser',
          dragDropText: 'ou déposer ici.',
          instructionsText: '(CSV ou XLSX, 10 Mo max)',
          errorText: 'Échec du téléversement.',
          removeFileLabel: 'Retirer le fichier',
        }}
      />
    </div>

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
acceptstringNative `<input accept>` — a comma-separated list of MIME types or extensions. Default undefined (any file). See MDN: "file type specifiers" for the full grammar.
allowReuploadbooleanWhether the Finished + Error states render the "upload another file" affordance below the status row. Default `true`. Set to `false` for single-shot uploads where the surface disappears on success.
aria-labelstringFile uploadAccessible label for the top-level region. Default `'File upload'`.
classNamestringAdditional classes merged after the component defaults.
filenamestringFilename shown in the uploading / finished / error states. In `uploading` + `finished`, the chip renders the bare name; in `error`, it's rendered in the secondary slot below the banner.
hideInstructionsbooleanHide the `(files accepted, max file size)` tertiary hint.
layoutenumpageLayout family. Default `'page'` — the 1056-wide desktop bar. `'panel'` is the 288-wide stacked card used in sidebars and drawers.
maxFilesnumberMax number of files accepted per selection (only meaningful when `multiple` is true). Excess files are sliced off the tail and reported via `onFilesRejected` with `code: 'too-many-files'`. Default: no cap.
maxFileSizenumberMax upload size per file in bytes. Files that exceed this are filtered out before `onFilesSelected` fires; the rejected files are reported via `onFilesRejected` with `code: 'file-too-large'`. Default: no cap. Client-side validation is convenience — server- side remains authoritative.
messagesPartial<FileUploadMessages>i18n overrides. Default strings come from the Figma source: `Upload File` / `or drag and drop it here.` / `(files accepted, max file size)` / `File upload failed.`
multiplebooleanAllow selecting multiple files per dialog. Default `false` — most SaaS flows upload one file at a time.
onFilesRejected((rejection: FileUploadRejection) => void)Fired when one or more files are rejected by client-side validation (`maxFileSize`, `maxFiles`). Receives the rejection code + the affected files plus context (max bytes / max count) so the consumer can render an error toast. Fires AFTER any accepted files in the same batch have already been emitted via `onFilesSelected` — the two callbacks together describe the full intent.
onFilesSelected((files: File[]) => void)Fired when the user picks files via the dialog or drops files on the zone — AFTER `maxFileSize` + `maxFiles` validation. Returns a plain `File[]` even when `multiple` is false (for a single-file picker, `files.length === 1`).
onRemove(() => void)Fired when the X on the Finished-state file chip is clicked.
onRetry(() => void)Fired when the upload button is clicked in the Error state.
progressnumberUpload progress 0–100. Only rendered when `state === 'uploading'`. Values outside that range are clamped — the bar never overflows.
sizeenummVisual size. Default `'m'` (Figma anchor).
stateenumidleControlled state. Omit for a simple drop-in that flips between `idle` → `uploading` → `finished` / `error` as the consumer runs through the file-upload lifecycle. Pass a value to fully own the surface (useful when the consumer already has Redux / Zustand / React Query state for the upload).

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<FileUpload
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

cta

Upload CTA button — DocumentAdd icon + "Upload File" label. Always visible on idle / finished / error; hidden on uploading / disabled. Forwards click to the native <input type="file">. i18n via `messages.uploadLabel`.

drag-text

"or drag and drop it here." — the co-equal instructional hint. Sits inline with the CTA on page layout, stacks below on panel. i18n via `messages.dragDropText`.

instructions

"(files accepted, max file size)" — tertiary constraint hint. Rendered in neutral-600. Hide via `hideInstructions={true}` when the caller supplies their own helper text. i18n via `messages.instructionsText`.

progress

Uploading-state progress bar. 180 × 2 px (default `m` size), two-tone (success-300 track + success-600 indicator) while uploading, collapses to a solid success-600 bar at 100 %. Renders role="progressbar" + aria-valuenow.

file-chip

Finished-state filename pill. Gray background, Close (×) button with aria-label. Panel layout adds a leading 14×14 Drag handle so the chip can be reordered in multi-file contexts. i18n for the close label via `messages.removeFileLabel`.

error-banner

Error-state red banner — AlertCircle + "File upload failed." message. Rendered on role="alert" so screen readers announce on state change. i18n via `messages.errorText`.

validation — maxFileSize / maxFiles / onFilesRejected

Client-side file validation runs before `onFilesSelected` fires. `maxFileSize` (bytes per file) filters oversize files; `maxFiles` caps the accepted batch when `multiple` is true. Rejected files surface via `onFilesRejected({ code, message, files, maxBytes, maxCount, attemptedCount })` with `code` set to `file-too-large` or `too-many-files`. Server-side validation remains authoritative.

reupload-zone

Finished / Error states — inner dashed rectangle carrying the Upload CTA + drag text, so the user can replace or retry without leaving the surface. Hide via `allowReupload={false}` for single-shot uploads.

Keyboard

The Upload CTA is a <button> — reachable via Tab, activated by Enter / Space — and forwards the click to the hidden <input type="file">, which opens the native file dialog. The hidden input is removed from the tab order (tabIndex=-1) so focus never lands on an invisible element. The file-chip Remove button on the Finished state is independently focusable with an aria-label. The progress track carries role="progressbar" with aria-valuemin / aria-valuemax / aria-valuenow so screen readers announce progress. The error surface renders role="alert" so errors announce on state change. Focus ring uses --oshon-focus-ring-color. Drag-and-drop is a visual convenience; every feature the drop surface exposes is also reachable via the button + file dialog.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-22

Do / Don't

✓ Do

Drop-in idle upload that lifts selection to a parent
<FileUpload
  onFilesSelected={(files) => handleUpload(files[0])}
/>
Controlled upload lifecycle with progress
const [state, setState] = useState<FileUploadState>('idle');
const [progress, setProgress] = useState(0);
const [filename, setFilename] = useState('');
return (
  <FileUpload
    state={state}
    progress={progress}
    filename={filename}
    onFilesSelected={(files) => {
      setFilename(files[0].name);
      setState('uploading');
      kickOffUploadThatTicks(files[0], setProgress, setState);
    }}
    onRemove={() => setState('idle')}
    onRetry={() => setState('uploading')}
  />
);
Panel layout in a sidebar — CSV-only, multi-select
<FileUpload
  layout="panel"
  accept=".csv,text/csv"
  multiple
  onFilesSelected={(files) => attachAll(files)}
/>
Disabled while the caller validates the parent form
<FileUpload
  state={formValid ? 'idle' : 'disabled'}
  onFilesSelected={handleUpload}
/>

✗ Don't

Hardcoded color for the progress bar
<FileUpload progress={60} className="[&_[role=progressbar]]:bg-green-500" />

Breaks white-labeling (principle #6). The bar reads --oshon-color-success-300 / success-600 so `applyTheme({ primarySeed })` can re-tint every upload in one DOM write. Override the token if you need a different hue; never reach for a Tailwind color shortcut.

Tracking upload progress inside FileUpload
FileUpload does NOT track progress internally.

Uploads are slow, retry-heavy, and tied to caller-owned state (React Query, Redux, Zustand, a Web Worker). FileUpload is a view; the caller owns the progress number and flips `state`. Embedding a fetch inside would lock callers into one upload strategy and kill cancellation semantics.

Using FileUpload to open a file dialog without a drop surface
<FileUpload layout="page" allowReupload={false} hideInstructions />

If you only need "pick a file", use a `<ButtonHug>` that fires `inputRef.current?.click()` on a hidden input. FileUpload is 1056 × 80 for a reason — it dedicates real estate to the drop affordance. Hiding everything leaves an oversized button with a disproportionate hit target.

Design rationale

Single FileUpload component with a `layout` axis instead of two separate components (FileUploadPage / FileUploadPanel) because the two Figma layouts share 95% of the same props surface — duplicating them would triple the API without adding capability. State is controlled by default (caller owns the upload lifecycle); an uncontrolled flow is achievable by omitting `state` and handling `onFilesSelected` locally. The hover surface (blue-stone-100 bg + primary-400 dashed border) fires internally when the browser reports drag-over; we deliberately do not expose `"hover"` as a controlled state value because only the browser knows when a drag is active. Finished and Error states switch from dashed to solid border because the surface is no longer inviting input — the border semantics signal "this slot now contains something". At 100% progress the two-tone bar collapses to a solid success-600 fill (Figma spec) as the "breath" before the surface flips to Finished. The native `<input type="file">` is kept in the DOM (sr-only) rather than re-created on click because some browsers only honor the file dialog on a trusted click event fired from the input itself; forwarding through a React ref preserves the user gesture.