Preview
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.
or drag and drop it here.
(files accepted, max file size)
quarterly-report.pdf
42%
or drag and drop it here.
File upload failed.
or drag and drop it here.
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)
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 add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add fileupload
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'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)
File name.pdf
45%
or drag and drop it here.
File upload failed.
or drag and drop it here.
or drag and drop it here.
(files accepted, max file size)
<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)
panel, size=m (Figma 288 × 122)
File name.pdf
45%
panel, size=m (Figma 288 × 122)
or drag and drop it here.
panel, size=m (Figma 288 × 122)
File upload failed.
or drag and drop it here.
panel, size=m (Figma 288 × 122)
or drag and drop it here.
(files accepted, max file size)
panel, size=m (Figma 288 × 122)
<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)
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)
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)
<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
or drag and drop it here.
(files accepted, max file size)
{
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
or drag and drop it here.
(files accepted, max file size)
{
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
ou déposer ici.
(CSV ou XLSX, 10 Mo max)
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
accept | string | — | Native `<input accept>` — a comma-separated list of MIME types or extensions. Default undefined (any file). See MDN: "file type specifiers" for the full grammar. |
allowReupload | boolean | | Whether 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-label | string | File upload | Accessible label for the top-level region. Default `'File upload'`. |
className | string | — | Additional classes merged after the component defaults. |
filename | string | — | Filename 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. |
hideInstructions | boolean | — | Hide the `(files accepted, max file size)` tertiary hint. |
layout | enum | page | Layout family. Default `'page'` — the 1056-wide desktop bar. `'panel'` is the 288-wide stacked card used in sidebars and drawers. |
maxFiles | number | — | Max 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. |
maxFileSize | number | — | Max 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. |
messages | Partial<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.` |
multiple | boolean | — | Allow 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. |
progress | number | — | Upload progress 0–100. Only rendered when `state === 'uploading'`. Values outside that range are clamped — the bar never overflows. |
size | enum | m | Visual size. Default `'m'` (Figma anchor). |
state | enum | idle | Controlled 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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
ctaUpload 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`.
progressUploading-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-chipFinished-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-bannerError-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 / onFilesRejectedClient-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-zoneFinished / 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
<FileUpload
onFilesSelected={(files) => handleUpload(files[0])}
/>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')}
/>
);<FileUpload
layout="panel"
accept=".csv,text/csv"
multiple
onFilesSelected={(files) => attachAll(files)}
/><FileUpload
state={formValid ? 'idle' : 'disabled'}
onFilesSelected={handleUpload}
/>✗ Don't
<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.
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.
<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.