# Field: File Upload (category: special)

URL: https://docs.forms.saastro.io/docs/file

<FieldPreview name="file" description="Interactive demo of file upload fields" />

## Overview

The file field renders a **native `<input type="file">`** styled to match the rest of the form. After selection, the file name(s) are listed below the input.

The field value holds real `File` objects:

- Single mode (default): `File | null` — `null` until the user picks a file, and again if they clear the selection.
- With `multiple: true`: `File[]` — an empty array if the selection is cleared.

Because the value is a `File`, it is not JSON-serializable — see [Submitting files](#submitting-files) for how files travel in the submit pipeline.

## Usage

### FormBuilder API

```tsx

const config = FormBuilder.create('application')
  .addField('resume', (f) =>
    f
      .type('file')
      .label('Resume')
      .accept('.pdf,.doc,.docx')
      .helperText('PDF or Word document'),
  )
  .addField('photos', (f) =>
    f
      .type('file')
      .label('Photos')
      .accept('image/*')
      .multiple(true),
  )
  .addStep('main', ['resume', 'photos'])
  .build();
```

### JSON Configuration

```json
{
  "type": "file",
  "label": "Resume",
  "accept": ".pdf,.doc,.docx",
  "multiple": false,
  "helperText": "PDF or Word document"
}
```

## Props

| Prop         | Type                                                  | Default | Description                                                                          |
| ------------ | ----------------------------------------------------- | ------- | ------------------------------------------------------------------------------------ |
| `type`       | `'file'`                                              | -       | Field type (required)                                                                |
| `accept`     | `string`                                              | -       | Accepted file types, passed to the native `accept` attribute (e.g. `"image/*"`, `".pdf,.doc"`) |
| `multiple`   | `boolean`                                             | `false` | Allow selecting multiple files; changes the value shape to `File[]`                  |
| `maxSize`    | `number`                                              | -       | Max file size in bytes — validated client-side on submit (shows a field error). Validate server-side too |
| `label`      | `string`                                              | -       | Field label                                                                           |
| `helperText` | `string`                                              | -       | Help text shown below the field                                                       |
| `columns`    | `Partial<Record<Breakpoint, number>>`                 | -       | Grid columns by breakpoint                                                            |
| `disabled`   | `boolean \| function \| ConditionGroup`               | `false` | Disable the input                                                                     |
| `hidden`     | `boolean \| function \| ConditionGroup \| Responsive` | `false` | Hide the field                                                                        |
| `readOnly`   | `boolean \| function \| ConditionGroup`               | `false` | Ignore file selection changes                                                         |

> **`accept` is a browser hint, not validation.** It filters the file picker dialog, but users can still drop or select other types in some browsers. Check `file.type` in your submit handler if the type matters.

## Validation

File fields **bypass schema validation**: the library applies a pass-through (`z.any()`) schema to `file` fields, so `.required()`, `.schema(...)`, and JSON validation rules have no effect on them. The one exception is `maxSize` — when set, oversized files produce a field error on submit.

If a file is mandatory, check the value in your submit handler:

```tsx
const handleSubmit = (data: Record<string, unknown>) => {
  const resume = data.resume as File | null;

  if (!resume) {
    alert('Please attach your resume');
    return;
  }
  if (resume.size > 5 * 1024 * 1024) {
    alert('File must be under 5 MB');
    return;
  }

  // ... send the data
};
```

## Submitting Files

`File` values cannot be serialized to JSON, so how they reach your backend depends on the submit path:

- **Hosted backend (`HubForm`)** — file fields are the attachment mechanism for hosted uploads. On submit, `File` values are uploaded directly to storage via presigned URLs and the submission carries them as an `attachments` array (`{ key, filename, size, mime }`). See the [HubForm guide](/docs/hub-form).
- **HTTP submit actions** — use `format: 'form-data'`; `File` values are appended natively to the `FormData` body. See the [Submit guide](/docs/submit).
- **Custom `onSubmit` handler** — you receive the raw `File` / `File[]` values and can upload them however you like.

## Upload on select (UploadAdapter)

For your own backend (R2 / S3 / anything), give the field an `uploadAdapter`. Files then upload **as soon as they're selected**, with a progress bar, and the field value becomes the adapter's result (e.g. `{ key }` or `{ url }`) instead of the raw `File`.

`createPresignedUploadAdapter` generalises the presigned-URL pipeline: you provide `getUploadTarget` (ask your backend for a short-lived upload URL), and the adapter PUTs the binary straight to it with real progress.

```tsx

const uploadAdapter = createPresignedUploadAdapter({
  getUploadTarget: async (file) => {
    const r = await fetch('/api/upload-url', {
      method: 'POST',
      body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
    }).then((res) => res.json());
    return {
      uploadUrl: r.uploadUrl, // presigned PUT URL
      headers: { 'Content-Type': file.type },
      result: { key: r.key }, // what gets stored as the field value
    };
  },
});

.addField('cv', (f) => f.type('file').label('CV').accept('.pdf').prop('uploadAdapter', uploadAdapter))
```

`uploadAdapter` is a function (not serializable), so it's for code-defined forms. Without it the field keeps its default behaviour (stores raw `File`s, uploaded on submit). The `useFileUpload` hook and `UploadAdapter` type are also exported if you want a fully custom file UI.

## Styling

The input uses built-in Tailwind classes (`h-10`, rounded border, muted file-button text). Extend or override them per part:

```tsx
.addField('resume', (f) =>
  f.type('file')
    .label('Resume')
    .accept('.pdf')
    .classNames({
      wrapper: 'bg-muted/50 p-4 rounded-lg',
      input: 'border-primary',
      label: 'font-medium',
    })
)
```

### Responsive Layout

```tsx
.addField('photos', (f) =>
  f.type('file')
    .label('Photos')
    .accept('image/*')
    .multiple(true)
    .columns({ default: 12, md: 6 })
)
```

## Related

- [HubForm](/docs/hub-form) - Hosted submit backend with file attachments
- [Submit](/docs/submit) - Submit actions, including `form-data` HTTP requests
- [Hidden](/docs/hidden) - Another special field with a pass-through schema

## Accessibility

- Uses the native `<input type="file">` element for built-in keyboard and screen reader support
- Label is associated with the input via the field wrapper
- Selected file names are displayed as visible text below the input
- Error messages are linked via `aria-describedby`
