Skip to content
Forms, switch product

File Upload

File Upload widgets are Input Fields that upload one file through a service you provide and store the result at the widget’s path. The widget is a one-line input: the box is the drop target and holds the upload button, while the file uploads the box itself becomes the progress bar, and once the upload finishes it shows the file name with a remove button. For several files, use the Multi File Upload.

The value bound at path is never the browser File. It is a plain JSON envelope, { id, name, size, type, status, error?, data? }, where data is exactly what your upload service resolved with, so a submit payload carries what your own endpoint returned. The widget never uploads without a service, never retries on its own, and never drops a refused or failed file silently: the file stays in the box with the reason below the input, and a file the server rejected also gets a retry button.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('cv', {
label: 'CV',
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "cv",
"label": "CV"
}
]
}

The widget does not ship a transport. When the user picks or drops a file, the widget hands the File to the uploadService you inject through the form’s dependencies, next to the markdown parser. It calls upload as soon as the file is picked, drives the progress bar from your onProgress calls, stores whatever upload resolved with in data, and awaits remove before clearing an uploaded file so your server can delete it.

Keep the service object at module level or memoized. A new dependencies identity is a new form config and re-initializes the form.

Both types are exported by @golemui/gui-shared.

type UploadService = {
upload(
file: File,
ctx: {
id: string; // FileItem.id, stable across progress and retry
path: string; // the widget path, useful to route uploads by field
onProgress?: (percentage: number) => void; // 0 to 100, drives the bar
signal: AbortSignal; // aborted on cancel and when a single file is replaced
},
): Promise<unknown>; // the resolved value becomes FileItem.data
remove?(item: FileItem): Promise<void>; // awaited behind a spinner before the item is cleared
};
type FileItem = {
id: string;
name: string;
size: number; // bytes
type: string; // MIME type as reported by the browser
status: 'uploading' | 'uploaded' | 'error';
error?: string; // the reason shown below the input when status is "error"
data?: unknown; // the server response, verbatim
};

A rejected upload marks the item as error, shows the rejection message below the input (or Upload failed when the error carries no message) and offers a retry button. A rejected remove keeps the item in place and turns its button into a retry of the removal.

In the Programmatic API, the service goes inside formConfig.dependencies.

import { gui } from '@golemui/gui-shared';
import { GuiForm } from '@golemui/gui-react';
import { uploadService } from './upload-service';
const config = {
formDef: [gui.inputs.fileUpload('cv', { label: 'CV' })],
formConfig: {
dependencies: { uploadService },
},
};
export function MyForm() {
return <GuiForm config={config} />;
}

In the JSON path, the service goes into the dependencies property of the config object.

{
"form": [
{ "kind": "input", "type": "fileUpload", "path": "cv", "label": "CV" }
]
}
import { GuiForm } from '@golemui/gui-react';
import formDef from './my-form.json';
import { uploadService } from './upload-service';
const config = {
formDef,
dependencies: { uploadService },
};
export function MyForm() {
return <GuiForm config={config} />;
}

A minimal service posts the file as FormData and returns the JSON your endpoint answers with, which the widget stores in data. The remove method uses that response to tell the server which file to delete.

upload-service.ts
import type { UploadService } from '@golemui/gui-shared';
// Module level: the same object on every render.
export const uploadService: UploadService = {
async upload(file, { path, signal }) {
const body = new FormData();
body.append('file', file, file.name);
body.append('field', path);
const response = await fetch('/api/uploads', {
method: 'POST',
body,
signal,
});
if (!response.ok) throw new Error(`Upload failed (${response.status})`);
return response.json(); // for example { key: 'u/8f1c', url: 'https://cdn.example.com/u/8f1c' }
},
async remove(item) {
const { key } = item.data as { key: string };
const response = await fetch(`/api/uploads/${encodeURIComponent(key)}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error(`Could not delete ${item.name}`);
},
};

fetch cannot report upload progress, so with this service the bar stays at zero until the promise settles. For a moving bar use XMLHttpRequest, forward xhr.upload.onprogress to onProgress, and call xhr.abort() when signal fires.

Server file cleanup is the host’s job, not the widget’s. remove runs on exactly two paths: when the user removes the file, and when a new pick replaces the current file (that call is not awaited, and a failure is only logged). Every other way a value disappears leaves the file on the server: a deleted repeater row, a field pruned because a state hid it, setData or a config replacement, and a form the user abandons without submitting. The widget cannot tell which of those makes the file unwanted.

Treat every upload result as provisional and reconcile your server files against the submitted value: promote the files the payload references and sweep the rest, for example with a TTL on unreferenced uploads. remove is a courtesy on the two paths above, not the cleanup mechanism.

Without an uploadService the widget does not break the form. The box renders disabled with an inline message, File uploads are not configured by default, and logs a console.error naming the widget, so a missing dependency is visible instead of swallowing files. Change the wording with missingServiceMessage.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('attachment', {
label: 'Attachment',
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "attachment",
"label": "Attachment"
}
]
}

Use these props to customize your File Upload widget. Every text prop is a Localizable, and the message props accept a {name} token that is replaced with the file name.

proptypedescription
acceptstring[]Accepted file types, for example ["image/*", ".pdf"]. Checked before uploading, since drag and drop bypasses the picker’s own filter, and passed to the picker as a hint
acceptMessageLocalizableError shown when a file does not match accept. Defaults to File type not accepted
buttonLabelLocalizableText of the upload button inside the box. Defaults to Upload file
cancelAriaLabelLocalizableAccessible name of the cancel button shown while uploading. Defaults to Cancel {name}
failedMessageLocalizableScreen reader announcement after a failed upload. Defaults to {name} failed to upload.
hintstringA description displayed below the box
iconstringCSS class string for a leading icon, the same convention used by Textinput
interruptedMessageLocalizableError shown for a restored file whose upload never finished. Defaults to {name} was not uploaded. Remove it and pick the file again.
maxSizenumberMaximum size per file, in bytes. Larger files are refused before uploading
maxSizeMessageLocalizableError shown when a file exceeds maxSize. Defaults to File exceeds the maximum size
missingServiceMessageLocalizableText shown inside the disabled box when no uploadService is provided. Defaults to File uploads are not configured
removeAriaLabelLocalizableAccessible name of the remove button. Defaults to Remove {name}
removedMessageLocalizableScreen reader announcement after a removal. Defaults to {name} removed.
removeIconstringCSS class string for the remove and cancel button icon. When omitted, a built-in icon is rendered
retryAriaLabelLocalizableAccessible name of the retry button shown on a failed file. Defaults to Retry {name}
retryIconstringCSS class string for the retry button icon. When omitted, a built-in icon is rendered
uploadedMessageLocalizableScreen reader announcement after a successful upload. Defaults to {name} uploaded.

disabled and readonly are standard widget fields, not props. A disabled widget keeps its box and button but accepts no files, and a readonly widget shows the current file without the upload button or the remove and retry actions.

Use the properties accept and maxSize to refuse files before they are uploaded. accept takes the same tokens as the native file input (.pdf, image/*, application/pdf) and is also passed to the picker as a hint, but the widget checks it again on drop because drag and drop bypasses the picker’s filter. maxSize is in bytes and applies per file. A refused file is not dropped silently: it stays in the box with the reason below the input and a remove button, so the user knows what to fix before picking another file. Change the wording with acceptMessage and maxSizeMessage.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('contract', {
hint: 'PDF or Word, 5 MB max',
accept: ['application/pdf', '.doc', '.docx'],
maxSize: 5242880,
acceptMessage: 'Only PDF or Word files are accepted',
maxSizeMessage: 'Keep the file under 5 MB',
label: 'Contract',
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "contract",
"label": "Contract",
"props": {
"hint": "PDF or Word, 5 MB max",
"accept": ["application/pdf", ".doc", ".docx"],
"maxSize": 5242880,
"acceptMessage": "Only PDF or Word files are accepted",
"maxSizeMessage": "Keep the file under 5 MB"
}
}
]
}

Use the property buttonLabel to change the text of the upload button, and icon to render a leading icon at the start of the box. The icon value is a CSS class string, the same convention used by Textinput.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('document', {
buttonLabel: 'Upload document',
icon: 'attach_file',
label: 'Document',
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "document",
"label": "Document",
"props": {
"buttonLabel": "Upload document",
"icon": "attach_file"
}
}
]
}

Use the property hint to add a description below the box.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('cv', {
hint: 'PDF preferred, two pages at most',
label: 'CV',
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "cv",
"label": "CV",
"props": {
"hint": "PDF preferred, two pages at most"
}
}
]
}

Use defaultValue, or the form data, to show a file that already lives on your server. Pass the same envelope the widget would have produced, with status: "uploaded" and whatever your endpoint returned in data. The widget shows the name with a remove button and never uploads it again. Removing it awaits uploadService.remove like any other file.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('contract', {
label: 'Signed contract',
defaultValue: {
id: 'srv-contract',
name: 'contract-signed.pdf',
size: 204800,
type: 'application/pdf',
status: 'uploaded',
data: {
url: 'https://cdn.example.com/uploads/contract-signed.pdf',
},
},
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "contract",
"label": "Signed contract",
"defaultValue": {
"id": "srv-contract",
"name": "contract-signed.pdf",
"size": 204800,
"type": "application/pdf",
"status": "uploaded",
"data": {
"url": "https://cdn.example.com/uploads/contract-signed.pdf"
}
}
}
]
}

A value restored with status: "uploading", for example from a draft saved while a file was still in flight, cannot resume: the File only existed in the session that picked it. The widget shows it as failed with interruptedMessage as the reason, so the user removes it and picks the file again. The stored value is left untouched: no change fires until the user removes the file. Use the property interruptedMessage to change the wording; the {name} token is replaced with the file name.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('draft', {
hint: 'Restored from a saved session while its upload was still running',
interruptedMessage: '{name} did not finish uploading. Remove it and pick the file again.',
label: 'Draft',
defaultValue: {
id: 'restored-draft',
name: 'draft-v2.pdf',
size: 98304,
type: 'application/pdf',
status: 'uploading',
},
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "draft",
"label": "Draft",
"props": {
"hint": "Restored from a saved session while its upload was still running",
"interruptedMessage": "{name} did not finish uploading. Remove it and pick the file again."
},
"defaultValue": {
"id": "restored-draft",
"name": "draft-v2.pdf",
"size": 98304,
"type": "application/pdf",
"status": "uploading"
}
}
]
}

The remove, cancel and retry buttons are icon buttons. removeIcon and retryIcon swap the built-in icons for any CSS class string, and removeAriaLabel, cancelAriaLabel and retryAriaLabel set their accessible names, with {name} replaced by the file name. Three more props feed the widget’s live region for screen readers: uploadedMessage, removedMessage and failedMessage. Upload a file whose name contains fail to see the retry button and trigger the failure announcement.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('photo', {
hint: 'Pick a file whose name contains "fail" to see the retry button and the failure announcement',
accept: ['image/*'],
removeAriaLabel: 'Discard {name}',
cancelAriaLabel: 'Stop uploading {name}',
retryAriaLabel: 'Try {name} again',
removeIcon: 'delete',
retryIcon: 'refresh',
uploadedMessage: '{name} is ready.',
removedMessage: '{name} was discarded.',
failedMessage: '{name} could not be uploaded.',
label: 'Profile photo',
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "photo",
"label": "Profile photo",
"props": {
"hint": "Pick a file whose name contains \"fail\" to see the retry button and the failure announcement",
"accept": ["image/*"],
"removeAriaLabel": "Discard {name}",
"cancelAriaLabel": "Stop uploading {name}",
"retryAriaLabel": "Try {name} again",
"removeIcon": "delete",
"retryIcon": "refresh",
"uploadedMessage": "{name} is ready.",
"removedMessage": "{name} was discarded.",
"failedMessage": "{name} could not be uploaded."
}
}
]
}

The value is a file envelope, so the widget takes the file validator: required and blockPendingUploads. blockPendingUploads defaults to true and fails while the file is still uploading or failed to upload, so a half-finished upload can never be submitted silently. Validation errors surface like any other input: below the box, with aria-invalid wired automatically. Override the default copy per rule with a messages object using the keys invalid, required and pendingUploads; see Features / Validators for the full rule list.

The demo validates on blur. Pick a file and tab away before it finishes to see the pendingUploads message. Once the upload completes, the next blur clears it.

file-upload.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.fileUpload('cv', {
hint: 'Required. Pick a file and tab away before it finishes to see the pending message',
label: 'CV',
validator: {
required: true,
messages: {
required: 'Please upload your CV',
invalid: 'Please upload your CV',
pendingUploads: 'Wait for your CV to finish uploading',
},
},
}),
];
file-upload.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "fileUpload",
"path": "cv",
"label": "CV",
"props": {
"hint": "Required. Pick a file and tab away before it finishes to see the pending message"
},
"validator": {
"type": "file",
"required": true,
"messages": {
"required": "Please upload your CV",
"invalid": "Please upload your CV",
"pendingUploads": "Wait for your CV to finish uploading"
}
}
}
]
}

File Upload widgets can be styled as explained in the Styling Guide. The progress fill and the text overlay are driven by a single custom property, --gui-upload-pct, which the widget sets on the bar while a file uploads.

Following you will find a list with the CSS Variables and a quick description of what you will style.

CSS VariableDescription
--gui-upload-pctUpload progress, set by the widget. Width of the fill and clip of the text overlay
--gui-intent-primaryProgress fill color
--gui-intent-primary-textText color over the progress fill
--gui-intent-errorBox border when invalid, service error text, remove button hover color
--gui-border-defaultUpload button border, icon and pills separators
--gui-border-focusBox border on focus and drag over, upload button hover border, retry button hover color
--gui-shadow-focusBox shadow on focus and drag over, action button focus ring
--gui-shadow-focus-errorBox shadow on focus when invalid
--gui-bg-defaultUpload button hover background
--gui-bg-surfaceRemove, cancel and retry button background
--gui-bg-disabledBox background when disabled
--gui-text-defaultUpload button and action button text color
--gui-text-disabledBox text color when disabled
--gui-widget-sizeUpload button height, minus 0.75rem
--gui-radiusUpload button corner radius
--gui-font-smUpload button font size
--gui-font-xsPercentage and service error font size
--gui-space-1Upload button start margin
--gui-space-2Gap between the box children, bar padding
--gui-space-3Upload button horizontal padding
--gui-transition-normalProgress fill transition

This is the anatomy of the File Upload Widget in case you want to use your CSS styles.

file-upload-anatomy.html
<gui-file-upload class="gui-field">
<span class="gui-label" id="fieldUid_label">
<span class="gui-label__text">CV</span>
<div class="gui-widget-hint" id="fieldUid_hint">PDF preferred, two pages at most</div>
</span>
<div class="gui-widget">
<div
role="group"
class="gui-widget-input gui-file-upload__box"
aria-label="CV"
aria-describedby="fieldUid_hint"
>
<!-- While the box is empty: the upload button -->
<button type="button" class="gui-file-upload__button">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 256 256"
fill="currentColor"
aria-hidden="true"
>
<path d="M240,136v64a16,16,0,0,1-…"></path>
</svg>
<span>Upload file</span>
</button>
<!-- While a file uploads (--uploading), once it is uploaded (no modifier) or when it failed (--error): the bar replaces the button -->
<div
class="gui-file-upload__bar gui-file-upload__bar--uploading"
data-status="uploading"
style="--gui-upload-pct: 37%"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="37"
aria-valuetext="cv.pdf, 37%"
>
<span class="gui-file-upload__fill" aria-hidden="true"></span>
<span class="gui-file-upload__text">
<span class="gui-file-upload__text-base"
><span class="gui-file-upload__name">cv.pdf</span
><span class="gui-file-upload__pct"> 37% </span></span
>
<span class="gui-file-upload__text-overlay" aria-hidden="true"
><span class="gui-file-upload__name">cv.pdf</span
><span class="gui-file-upload__pct"> 37% </span></span
>
</span>
<!-- Cancel while uploading, remove once uploaded; a failed file also gets a gui-file-upload__action--retry button -->
<button type="button" class="gui-file-upload__action" aria-label="Cancel cv.pdf">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 256 256"
fill="currentColor"
aria-hidden="true"
>
<path d="M165.66,101.66,139.31,12…"></path>
</svg>
</button>
</div>
<input
type="file"
class="gui-visually-hidden gui-file-upload__input"
tabindex="-1"
id="fieldUid"
/>
</div>
</div>
<div class="gui-visually-hidden" role="status" aria-live="polite"></div>
<ul class="gui-validator gui-validator--empty" id="fieldUid_errors" role="alert"></ul>
</gui-file-upload>