Skip to content

File Fields

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

Features

  • 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.

Anatomy

Choose a file
Trigger
File.pdf
Remove button
File Entry
Help text
Description or Error Message

Building a File Field Component

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>

Building a Dropzone Component

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>

Validation

HTML Constraints

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>

Standard Schema

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>

Mixed Validation

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>

Usage

Disabled

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.

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>

Allowing Picking Directories

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>

Uploading Files

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>

Initial Data

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!

API

useFileField

Props

NameTypeDescription
acceptThe file types that are accepted (e.g. "image/*", "application/pdf").
allowDirectoryWhether the field allows directory selection.
disabledWhether the field is disabled.
labelThe label of the field.
modelValueThe model value for the field.
multipleWhether the field allows multiple files to be selected.
nameThe name of the field.
onUpload
(context: FileUploadContext) => Promise<string>
Handles the file upload, this function is called when the user selects a file, and is called for new picked files.
removeButtonLabelThe label for the remove file button.
requiredWhether the field is required.
schemaThe schema for the field.
valueThe value of the field.

Returns

NameTypeDescription
clearClear the files, aborts any pending uploads.
displayErrorDisplay the error message for the field.
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; }>
The props for the dropzone element, usually the root element.
entriesThe file entries that are currently picked.
entry
Ref<{ id: string; file: { readonly lastModified: number; readonly name: string; readonly webkitRelativePath: string; readonly size: number; readonly type: string; arrayBuffer: () =>
The file entry that is currently picked.
errorMessageThe error message for the field.
errorMessageProps
Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }>
Props for the error message element.
errorsThe errors for the field.
fieldValueThe value of the field.
inputElThe captured input element.
inputPropsThe props for the input element.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isDraggingWhether the dropzone element has items being dragged over it.
isTouchedWhether the field is touched.
isUploadingWhether the field is uploading, if multiple files are picked, this will be true if any of the files are uploading.
isValidWhether the field is valid.
removeRemove a an entry from the list, if no key is provided, the last entry will be removed.
removeButtonProps
Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }>
The props for the remove file button.
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>
Shows the file picker with the given options. Useful for a picker-type implementations.
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; }>
The props for the trigger element.
validate
(mutate?: boolean) => Promise<ValidationResult<unknown>>
Validates the field.
validityDetailsValidity details for the input element.

useFileEntry

Props

NameTypeDescription
fileThe file object.
idThe id of the file entry.
isUploadingWhether the file is being uploaded.
uploadResultThe result of the upload.

Returns

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; }>