React File Upload with Size and Type Limits
Updated 9 October 2026. I built every example from the first version of this post as a real project and ran it in Chromium, Firefox and WebKit, with oks-ui 1.4.1, TypeScript 7 in strict mode and Playwright 1.63. Several things in the first version were wrong, and one of them meant its main example enforced nothing: the maxFileSize and maxFiles props shown in section 3 are ignored by FormFieldSet. The fixes are below, with the gaps I found in oks-ui itself and a table of results near the end. File uploads look simple until you handle the real cases: someone drops a 40 MB video into an avatar picker, or picks a PDF where you wanted a photo. This tutorial builds an upload field that handles those politely.
import "oks-ui/tokens.css"; // the design tokens every component stylesheet reads
import { FormFieldSet } from "oks-ui/form-field-set";
import "oks-ui/form-field-set.css";
import "oks-ui/modal.css"; // needed when the field has multiple files: its file list opens in a dialog
The first line loads the design tokens that every component stylesheet reads. Without it the components still render, but with fallback colours: in my test a danger button came out the same blue as a primary one. The modal.css line is new in this version, and I explain it in section 5.
The field in four states.
1. A basic file field
The value is an array of the browser's own File objects:
const [files, setFiles] = useState<File[]>([]);
<FormFieldSet
type="file"
name="attachment"
label="Attachment"
onChange={(v: unknown) => setFiles(v as File[])}
/>
This type-checks, and choosing a file gave an array with one File in it, with its real name and size.
2. Drag and drop
ui="dropzone" turns the field into a drop area, with your own wording:
<FormFieldSet
type="file"
name="attachment"
label="Attachment"
ui="dropzone"
isDroppable
dropLabel="Drop your file here"
dropHelperText="or click to browse — PDF or PNG, up to 5 MB"
browseLabel="Choose a file"
/>
All three labels showed up as written, and dropping files onto the area selected them in all three browsers. Without the labels the area says "Choose a file or drag & drop it here". Note that dropHelperText is only text: writing "PDF or PNG, up to 5 MB" there enforces nothing. The next section is about enforcing it. Keep the click-to-browse path. Drag and drop is impossible on a phone and awkward with a keyboard. I found a problem with it, though. The hidden file input that catches the click only covers the top strip of the drop area, about 28 pixels of a 98 pixel box. I clicked a grid of points across the area, and only the top row opened the file chooser. Clicking the "Drop your file here" text, or the "or click to browse" line, did nothing in any of the three browsers. Keyboard users are fine: focusing the field and pressing Enter or Space opened the chooser in all three.
3. Limits that are enforced, not just written
Three settings do the work: accept for file types, maxFileSize in bytes, and maxFiles. How you pass them matters. The first version put all three on FormFieldSet as props:
<FormFieldSet
type="file"
name="photos"
label="Photos"
multiple
accept="image/png,image/jpeg"
maxFileSize={5 * 1024 * 1024} // ignored
maxFiles={4} // ignored
preview="thumbnails"
description="PNG or JPEG, up to 5 MB each, 4 files."
/>
I tested exactly that. accept worked: a .txt file was refused. maxFileSize and maxFiles did nothing: a file one byte over 5 MB was kept, and so were five files. FormFieldSet reads those two limits only from its validation rules and passes the result down, so a plain prop is overwritten with nothing. Put them in the rules:
<FormFieldSet
type="file"
name="photos"
label="Photos"
ui="dropzone"
isDroppable
multiple
accept="image/png,image/jpeg"
preview="thumbnails"
description="PNG or JPEG, up to 5 MB each, 4 files."
validation={{ rules: { accept: "image/png,image/jpeg", maxFileSize: 5 * 1024 * 1024, maxFiles: 4 } }}
/>
With the limits in validation.rules, a file of exactly 5 MB was kept, a file one byte over was refused, and the fifth file was refused, whether the files came from the picker or from a drop, in all three browsers. If you use the FileField component itself, imported from the same entry point, its maxFileSize and maxFiles props do work directly. When a file is refused, the field shows a line such as "Skipped: over5mb.png (too large (max 5.00 MB))" or "Skipped: e.png (max files (4))" in the drop area. Two cautions:
Only the dropzone shows it. With the default inline field the file is refused silently: nothing is selected and nothing is said. Use
ui="dropzone"if you want the person to be told.It is not announced. The "Skipped" line is a plain
<span>. It is not a live region and the input does not point at it, so a screen reader user gets no news that their file was refused. If that matters, mirror the message in your own live region.preview="thumbnails"shows picked images as small tiles with a remove button each, named "Remove small.png", and an "Add image" tile. Removing one updated the value. Always write the limits in the description too. An error is a poor substitute for knowing the rule in advance.
4. The same rules inside a form
Inside an oks-ui Form, express the limits as validation rules so the form will not submit without a file:
<FormFieldSet
type="file"
name="resume"
label="Résumé"
accept=".pdf"
validation={{
rules: { required: true, accept: ".pdf", maxFileSize: 2 * 1024 * 1024 },
message: { maxFileSize: "That file is over 2 MB — try compressing it." },
}}
/>
Two things to know. Submitting with no file showed "This field is required" and did not submit. But the custom maxFileSize message from the first version never appeared. A 2.1 MB PDF is refused by the field as soon as it is picked, before validation runs. The field then looks empty, so on submit the person sees "This field is required", which is misleading. With ui="dropzone" they also see the "Skipped: … (too large (max 2.00 MB))" line, which is clearer. So for a form, I would add ui="dropzone" and not rely on the custom message.
5. Showing upload progress
The field can show per-file progress while you upload. Give it a function that returns each file's state:
const [progress, setProgress] = useState<Record<string, number>>({});
<FormFieldSet
type="file"
name="photos"
multiple
onChange={(v: unknown) => upload(v as File[])}
getFileUploadState={(file) => ({
status: progress[file.name] === 100 ? "completed" : "uploading",
progress: progress[file.name] ?? 0,
})}
/>
Your upload function sends each file and updates progress. I simulated that with timers. The progress bars exist, one per file, but they are not on the field itself. With several files the field says "2 files selected", and the bars live in a "Selected files" dialog that opens from the field's "View selected files" button. In it, each file showed its bytes sent, "Uploading..." with a percentage and a bar, then "Completed" when the state changed. So tell people where to look, or open that list for them. That dialog is why oks-ui/modal.css is in the imports above. Without it I got an unstyled dialog stuck to the bottom of the window: 900 pixels wide and 386 tall in Chromium and WebKit, and in Firefox starting at 680 pixels in a 700 pixel window, which is almost entirely off screen. With the stylesheet it was a centred 640 pixel dialog with a backdrop.
6. Check the limits on the server too
Browser-side limits are a convenience, not a defence, because anything can post to your endpoint. Repeat every check on the server: the file type (by content, not just the name), the size, and how many files. Store uploads outside your web root or in object storage, and give them names you generate rather than the ones you were sent. I tested why the first one matters. The field accepted a PNG renamed fake.pdf, because it checks the name and the type the browser reports, not the contents. A small test server that reads the first bytes of the upload accepted a real PDF and answered 415 to the renamed PNG. The same server also showed the naming point: it accepted a file named ../../etc/passwd.pdf and echoed that name straight back, because my toy server trusted it. Yours should not.
A note on accessibility
The field is a real <input type="file"> behind the drop zone, so the keyboard path works: Tab to it and press Enter or Space to open the chooser. axe-core found no problems in light mode, with a refused file showing. In dark mode the "Skipped" text measured 4.26 to 1 contrast (#e83535 on near-black), under the 4.5 to 1 that WCAG AA asks for. That is a gap in oks-ui 1.4.1 and I have noted it; nothing is released or fixed yet. I did not test with a screen reader.
Results
Every result, in all three browsers.
Check your own upload endpoint
This script posts five files straight to your upload endpoint, the way anything on the internet can, and prints the status code for each. Files that should be refused must not get a 2xx answer. It needs Node 18 or later and no packages.
// node check-upload-server.mjs http://localhost:3000/api/upload file
const [endpoint, field = "file"] = process.argv.slice(2);
// 8 bytes of PNG signature followed by padding: a PNG pretending to be a PDF.
const fakePdf = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ...new Array(64).fill(0)])]);
const realPdf = new Blob(["%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n"]);
const empty = new Blob([]);
const huge = new Blob([new Uint8Array(25 * 1024 * 1024)]); // 25 MB
const cases = [
["a real PDF (should be accepted)", realPdf, "report.pdf"],
["a PNG renamed .pdf (should be refused)", fakePdf, "report.pdf"],
["an empty file (should be refused)", empty, "empty.pdf"],
["a 25 MB file (should be refused if your limit is lower)", huge, "huge.pdf"],
["a path in the file name (should not be trusted)", realPdf, "../../etc/passwd.pdf"],
];
for (const [label, blob, name] of cases) {
const body = new FormData();
body.append(field, blob, name);
try {
const response = await fetch(endpoint, { method: "POST", body });
console.log(`${String(response.status).padEnd(4)} ${label}`);
} catch (error) {
console.log(`ERR ${label}: ${error.message}`);
}
}
Against my toy server it printed 200 for the real PDF, 415 for the renamed PNG, the empty file and the 25 MB file, and 200 for the path in the name. The 25 MB file was refused only because its contents were not a PDF: my toy server has no size limit, and the 200 on the last line is the problem described above. Yours should refuse files that are too large and ignore the name it was sent. See every option on the file field page.
Limits
I used small generated files, plus two large ones made of padding bytes. Real photos, very large uploads and slow connections were not tested.
Progress was simulated with timers. I did not upload to a real server with progress events.
I did not test on a phone, or with a screen reader.
I tested one browser window size, and did not test the file chooser dialog itself, only the event that opens it.
Originally published at oks-ui.com.
