Skip to content

File Fields

File fields are a type of input field that allows users to submit or upload files.

  • Uses input element.
  • Labeling, Descriptions, and error messages are automatically linked to their corresponding elements.
  • Validation support with native HTML constraint validation or Standard Schema validation.
  • Support for v-model binding.
  • Basic upload handling support.
  • Multiple file selection support.
  • Drag and drop support for “dropzone” components.
  • Auto previews for images and videos.
Choose a file
Trigger
File.pdf
Remove button
File Entry
Help text
Description or Error Message

You can start by importing the useFileField composable and using it in your file field component.

The useFileField composable returns binding objects for the elements shown in the anatomy. You will use v-bind to bind them to the corresponding DOM elements.

<script setup lang="ts">
import FileField from './FileField.vue';
</script>
<template>
<FileField label="File" />
</template>
<script setup lang="ts">
import { useFileField, type FileFieldProps, FileEntry } from '@formwerk/core';
const props = defineProps<FileFieldProps>();
const {
inputProps,
triggerProps,
entries,
errorMessageProps,
errorMessage,
remove,
removeButtonProps,
isUploading,
} = useFileField(props);
</script>
<template>
<div class="field">
<input v-bind="inputProps" class="sr-only" />
<button v-bind="triggerProps" class="trigger">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
viewBox="0 0 256 256"
>
<path
v-if="isUploading"
class="icon-uploading"
d="M236,128a108,108,0,0,1-216,0c0-42.52,24.73-81.34,63-98.9A12,12,0,1,1,93,50.91C63.24,64.57,44,94.83,44,128a84,84,0,0,0,168,0c0-33.17-19.24-63.43-49-77.09A12,12,0,1,1,173,29.1C211.27,46.66,236,85.48,236,128Z"
></path>
<path
v-else
d="M248,128a56.06,56.06,0,0,1-56,56H48a40,40,0,0,1,0-80H192a24,24,0,0,1,0,48H80a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H48a24,24,0,0,0,0,48H192a40,40,0,0,0,0-80H80a8,8,0,0,1,0-16H192A56.06,56.06,0,0,1,248,128Z"
></path>
</svg>
Choose a file
</button>
<p v-if="!entry" class="empty-state">No file selected</p>
<div v-else class="file-entry">
<span>{{ entry.file.name }}</span>
<button v-bind="removeButtonProps" class="delete-button" @click="remove">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
viewBox="0 0 256 256"
>
<path
d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"
></path>
</svg>
</button>
</div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error">
{{ errorMessage }}
</div>
</div>
</template>
<style scoped>
.field {
--color-primary: #10b981;
--color-text-primary: #333;
--color-text-secondary: #666;
--color-border: #d4d4d8;
--color-focus: var(--color-primary);
--color-error: #f00;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: max-content;
padding: 0.5rem 1rem;
font-size: 14px;
font-weight: 500;
color: white;
background-color: var(--color-primary);
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.trigger:hover {
background-color: color-mix(in srgb, var(--color-primary) 85%, black);
}
.trigger:focus {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, white);
}
.icon {
width: 16px;
height: 16px;
fill: currentColor;
}
.empty-state {
font-size: 13px;
color: var(--color-text-secondary);
margin: 0;
}
.file-entry {
font-size: 13px;
color: var(--color-text-primary);
padding: 0.25rem 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.error {
font-size: 13px;
color: var(--color-error);
}
.delete-button {
background: none;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
color: var(--color-text-secondary);
}
.delete-button:hover {
color: var(--color-error);
}
.icon-uploading {
animation: spin 1s linear infinite;
transform-origin: center;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

You can use useFileField to build a “dropzone” component that allows users to drag and drop files to upload them.

In this case, we can use the FileEntry component to render the file entry, the FileEntry component exposes various slot props to manage, and preview the file entry if it is an image or a video.

<script setup lang="ts">
import Dropzone from './Dropzone.vue';
</script>
<template>
<Dropzone label="Dropzone" />
</template>
<script setup lang="ts">
import { useFileField, type FileFieldProps, FileEntry } from '@formwerk/core';
const props = defineProps<FileFieldProps>();
const {
inputProps,
dropzoneProps,
triggerProps,
entries,
errorMessageProps,
errorMessage,
} = useFileField(props);
</script>
<template>
<div v-bind="dropzoneProps" class="dropzone">
<input v-bind="inputProps" class="sr-only" />
<div v-if="entries.length === 0" class="empty-state">
<button v-bind="triggerProps" class="trigger">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
viewBox="0 0 256 256"
>
<path
d="M248,128a56.06,56.06,0,0,1-56,56H48a40,40,0,0,1,0-80H192a24,24,0,0,1,0,48H80a8,8,0,0,1,0-16H192a8,8,0,0,0,0-16H48a24,24,0,0,0,0,48H192a40,40,0,0,0,0-80H80a8,8,0,0,1,0-16H192A56.06,56.06,0,0,1,248,128Z"
></path>
</svg>
Choose a file
</button>
<p class="empty-message">No file selected</p>
</div>
<div v-else class="file-grid">
<ul>
<FileEntry
as="li"
v-for="entry in entries"
v-bind="entry"
class="file-entry"
v-slot="{ removeButtonProps, previewProps, hasPreview }"
>
<component
:is="previewProps.as"
v-bind="previewProps"
class="preview"
/>
<button v-bind="removeButtonProps" class="delete-button">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
viewBox="0 0 256 256"
>
<path
d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"
></path>
</svg>
</button>
<span v-if="!hasPreview" class="filename">{{ entry.file.name }}</span>
</FileEntry>
</ul>
</div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error">
{{ errorMessage }}
</div>
</div>
</template>
<style scoped>
.dropzone {
--color-primary: #10b981;
--color-text-primary: #333;
--color-text-secondary: #666;
--color-border: #d4d4d8;
--color-focus: var(--color-primary);
--color-error: #f00;
--color-background: #fff;
--color-background-hover: color-mix(
in srgb,
var(--color-background) 97%,
black
);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
max-width: 32rem;
padding: 1rem;
border: 2px dashed var(--color-border);
border-radius: 0.5rem;
transition: all 0.3s ease;
}
.dropzone:hover {
border-color: var(--color-primary);
background-color: var(--color-background-hover);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
margin-top: 0.75rem;
}
.trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 14px;
font-weight: 500;
color: white;
background-color: var(--color-primary);
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.trigger:hover {
background-color: color-mix(in srgb, var(--color-primary) 85%, black);
}
.trigger:focus {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, white);
}
.icon {
width: 16px;
height: 16px;
fill: currentColor;
}
.empty-message {
font-size: 13px;
color: var(--color-text-secondary);
margin: 0;
}
.file-grid ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.file-entry {
position: relative;
width: 4rem;
height: 4rem;
border: 1px solid var(--color-border);
border-radius: 0.375rem;
}
.preview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.375rem;
}
.delete-button {
position: absolute;
top: -0.5rem;
right: -0.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0.25rem;
color: white;
background-color: var(--color-primary);
border: none;
border-radius: 9999px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.delete-button:hover {
background-color: var(--color-error);
}
.filename {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.25rem;
font-size: 12px;
color: var(--color-text-primary);
background-color: rgba(255, 255, 255, 0.8);
border-radius: 0 0 0.375rem 0.375rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.error {
font-size: 13px;
color: var(--color-error);
}
</style>

You can use the following properties to validate the file field with native HTML constraint validation:

NameTypeDescription
requiredbooleanWhether the file field is required.

Here is an example of how to use the required property to make the file field required.

<script setup lang="ts">
import FileField from './FileField.vue';
</script>
<template>
<FileField label="File" required />
</template>

You can use the useFileField composable to validate the file field with Standard Schema.

In this example, we are validating the file size to be 1MB maximum.

<script setup lang="ts">
import { z } from 'zod';
import FileField from './FileField.vue';
const schema = z.object({
size: z.number().max(1 * 1024 * 1024, 'Size must be less than 1MB'),
});
</script>
<template>
<FileField label="File" :schema="schema" />
</template>

All file fields created with Formwerk support mixed validation, which means you can use both HTML constraints and Standard Schema validation to validate the field, and they work seamlessly together.

Note that HTML constraints are validated first, so any errors from the HTML constraints will be displayed first. Then, once all HTML constraints are satisfied, the Standard Schema is validated.

Here we are mixing both the required HTML constraint and the Zod size validation.

<script setup lang="ts">
import { z } from 'zod';
import FileField from './FileField.vue';
const schema = z.object({
size: z.number().max(1 * 1024 * 1024, 'Size must be less than 1MB'),
});
</script>
<template>
<FileField label="File" :schema="schema" required />
</template>

Use disabled to mark fields as non-interactive. Disabled fields are not validated and are not submitted.

<script setup lang="ts">
import FileField from './FileField.vue';
</script>
<template>
<FileField label="Disabled Field" disabled />
</template>

One important thing is to no forget the dropzoneProps binding object, which contains the listeners needed to make the dropzone work.

You can also build your own FileEntry component with the useFileEntry composable.

This dropzone component isn’t multiple by default, but you can use the multiple prop to allow users to select multiple files.

You can use the multiple prop to allow users to select multiple files.

<script setup lang="ts">
import Dropzone from './Dropzone.vue';
</script>
<template>
<Dropzone label="Multiple Files" multiple />
</template>

You can use the allowDirectory prop to allow users to pick directories if the file is multiple.

<script setup lang="ts">
import Dropzone from './Dropzone.vue';
</script>
<template>
<Dropzone label="Allow Directory" multiple allow-directory />
</template>

The useFileField composable accepts an onUpload handler that is called with the file to be uploaded. The onUpload handler receives a FileUploadContext object with the following properties:

NameTypeDescription
fileThe file that was just picked.
keyThe key of the entry containing the file.
signalA signal that can be used to abort the upload.

The upload handler should return a string value that will be swapped with the file in the form when it submits. The string value is usually an identifier that your server can use to identify the file.

<script setup lang="ts">
import { ref } from 'vue';
import { type FileUploadContext } from '@formwerk/core';
import FileField from './FileField.vue';
function onUpload({ file }: FileUploadContext) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(Date.now().toString());
}, 1000);
});
}
const value = ref<string>();
</script>
<template>
Field value: {{ value }}
<FileField label="File" v-model="value" @upload="onUpload" />
</template>

At the moment, useFileField doesn’t support initial data because you cannot really set a “file” value to an input element due to security restrictions. There are a few ideas we have like:

  • Given a URL, we reconstruct the file object and set it as the initial value.
  • Create a “fake” file object that we can set as the initial value.

We are open to suggestions, join our Discord server or open an issue if you have any ideas!

NameTypeDescription
NameTypeDescription
clear
controlId
descriptionPropsProps for the description element.
dropzoneProps
Ref<{ onDragenter(evt: DragEvent): void; onDragover(evt: DragEvent): void; onDragleave(evt: DragEvent): void; onDrop(evt: DragEvent): void; onClick(e: MouseEvent): void; role: string; 'data-dragover': boolean; 'aria-label': string; }>
entries
entry
Ref<{ id: string; file: { readonly lastModified: number; readonly name: string; readonly webkitRelativePath: string; readonly size: number; readonly type: string; arrayBuffer: () =>
errorMessageThe error message for the field.
errorMessagePropsProps for the error message element.
errorsThe errors for the field.
field
fieldValueThe value of the field.
inputEl
inputProps
isBlurredWhether the field is blurred.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isDragging
isTouchedWhether the field is touched.
isUploading
isValidWhether the field is valid.
labelPropsProps for the label element.
remove
removeButtonProps
Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }>
setBlurredSets the blurred state for the field.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
showPicker
(opts?: FilePickerOptions) => Promise<void>
submitErrorMessageThe error message for the field from the last submit attempt.
submitErrorsThe errors for the field from the last submit attempt.
triggerProps
Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }>
validate
(mutate?: boolean) => Promise<ValidationResult<unknown>>
Validates the field.
NameTypeDescription
fileThe file object.
idThe id of the file entry.
isUploadingWhether the file is being uploaded.
uploadResultThe result of the upload.
NameTypeDescription
isUploaded
name
previewProps
Ref<{ as: string; src: string; alt: string; 'aria-label'?: undefined; controls?: undefined; muted?: undefined; loop?: undefined; autoplay?: undefined; playsinline?: undefined; } | { as: string; 'aria-label': string; ... 6 more ...; alt?: undefined; } | { ...; }>
remove
removeButtonProps
Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }>