<script setup lang="ts">
import { ref } from 'vue'
import { FileUpload } from '@hina-ui/vue'
const files = ref<File[]>([])
</script>
<template>
<FileUpload
v-model="files"
multiple
accept="image/*"
aria-label="Upload screenshots"
class="w-96"
/>
</template>
Usage
import { FileUpload } from '@hina-ui/vue'
The file upload is a drop area plus a list of the chosen files: clicking the area opens the system file dialog, and dropping files onto it picks them as well. v-model binds the chosen files, a single File or null by default and a File array with multiple. The component only picks and lists files; it does not upload them, which is up to the caller once the files are in hand. Attributes it does not declare land on the drop area, so name it with aria-label or aria-labelledby.
Value: empty
<script setup lang="ts">
import { ref } from 'vue'
import { FileUpload, Stack, Text } from '@hina-ui/vue'
const file = ref<File | null>(null)
</script>
<template>
<Stack gap="sm" align="stretch" class="w-96">
<FileUpload v-model="file" aria-label="Upload cover" />
<Text tone="muted" size="sm">Value: {{ file?.name ?? 'empty' }}</Text>
</Stack>
</template>
Examples
Multiple files
multiple allows several files at once; a later pick appends to the list and duplicates are kept once.
0 files chosen
<script setup lang="ts">
import { ref } from 'vue'
import { FileUpload, Stack, Text } from '@hina-ui/vue'
const files = ref<File[]>([])
</script>
<template>
<Stack gap="sm" align="stretch" class="w-96">
<FileUpload v-model="files" multiple aria-label="Upload attachments" />
<Text tone="muted" size="sm">{{ files.length }} files chosen</Text>
</Stack>
</template>
Type, size and count limits
accept works like the native attribute and takes extensions or MIME types; maxSize caps a single file in bytes; maxFiles caps the count with multiple. Files that fail never enter the list; they are handed out through the reject event with the reason for each.
<script setup lang="ts">
import { ref } from 'vue'
import { FileUpload, Stack, Text, type FileUploadRejection } from '@hina-ui/vue'
const files = ref<File[]>([])
const notice = ref('')
const reasons: Record<FileUploadRejection['reason'], string> = {
type: 'not an image',
size: 'over 2 MB',
count: 'more than 3 files',
}
function onReject(rejections: FileUploadRejection[]) {
notice.value = rejections.map(r => `${r.file.name}: ${reasons[r.reason]}`).join('; ')
}
</script>
<template>
<Stack gap="sm" align="stretch" class="w-96">
<FileUpload
v-model="files"
multiple
accept="image/*"
:max-size="2 * 1024 * 1024"
:max-files="3"
aria-label="Upload images"
@reject="onReject"
>
Up to 3 images, 2 MB each
</FileUpload>
<Text v-if="notice" tone="danger" size="sm">{{ notice }}</Text>
</Stack>
</template>
Pick and send
Some places hand files straight to an upload flow and show the results elsewhere, such as an upload tile in an image library. Set list to false to keep only the drop area, then take the files on change and reset the value.
Handed to the upload flow: none
<script setup lang="ts">
import { ref } from 'vue'
import { FileUpload, Stack, Text } from '@hina-ui/vue'
const files = ref<File[]>([])
const sent = ref<string[]>([])
function send(next: File | File[] | null) {
const picked = Array.isArray(next) ? next : next ? [next] : []
sent.value.push(...picked.map(file => file.name))
files.value = []
}
</script>
<template>
<Stack gap="sm" align="stretch" class="w-96">
<FileUpload
:model-value="files"
:list="false"
multiple
accept="image/*"
aria-label="Upload images"
@update:model-value="send"
>
Drop or click, uploads on pick
</FileUpload>
<Text tone="muted" size="sm">
Handed to the upload flow: {{ sent.length ? sent.join(', ') : 'none' }}
</Text>
</Stack>
</template>
States
invalid gives the drop area a warning color, disabled blocks picking and removing, and loading swaps the icon for a spinner and blocks picking while an upload runs. The icon slot replaces the icon in the area or button, and when the root is given a fixed size the area fills it.
<script setup lang="ts">
import { ImagePlus } from '@lucide/vue'
import { FileUpload, Stack } from '@hina-ui/vue'
</script>
<template>
<Stack align="stretch" class="w-96">
<FileUpload invalid aria-label="Invalid" />
<FileUpload disabled aria-label="Disabled" />
<FileUpload loading aria-label="Uploading">Uploading</FileUpload>
<FileUpload aria-label="Add image" class="aspect-square w-40">
<template #icon>
<ImagePlus />
</template>
Add image
</FileUpload>
</Stack>
</template>
In a form
Inside a FormField the label points at the drop area, and the description and error message are rendered by the field; validation rules and submission belong to the Form. The value is a File, so size and type rules go straight into the schema.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, FileUpload, Form, FormField, Text } from '@hina-ui/vue'
const schema = v.object({
cover: v.pipe(
v.instance(File, 'Pick a cover image'),
v.check(file => file.size <= 2 * 1024 * 1024, 'The image must be 2MB or smaller'),
),
})
const values = reactive({ cover: null as File | null })
const saved = ref('')
async function save(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 600))
saved.value = (data as { cover: File }).cover.name
}
</script>
<template>
<Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
<FormField name="cover" label="Cover" description="An image up to 2MB" required>
<FileUpload v-model="values.cover" accept="image/*" />
</FormField>
<Button type="submit" :loading="submitting" class="self-start">Upload</Button>
<Text v-if="saved" tone="muted" size="sm">Uploaded: {{ saved }}</Text>
</Form>
</template>
Behavior
- Clicking the area or the button opens the system file dialog; dragging files over the area turns its border to the accent color, and dropping picks them.
- A single pick replaces the previous file; with
multiplepicks append, and files beyondmaxFilesare rejected. - Every listed file shows its name and readable size, images show a thumbnail, and the remove button drops that file from the list.
- The default slot replaces the text inside the area or the button.
Accessibility
- The drop area is a button reachable by keyboard; Enter or Space opens the file dialog, and the hidden file input is invisible to assistive technology.
- Each remove button carries a localized name that includes the file name.
- Name the drop area with
aria-labeloraria-labelledby.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | File | File[] | null | null | The chosen files, an array with multiple |
multiple | boolean | false | Whether several files may be picked |
accept | string | — | Accepted types, as in the native attribute |
maxSize | number | — | Size cap of a single file in bytes |
maxFiles | number | — | Count cap with multiple |
variant | 'area' | 'button' | 'area' | Variant |
list | boolean | true | Whether the chosen files are listed |
preview | boolean | true | Whether images show a thumbnail |
name | string | — | Form field name |
loading | boolean | false | Whether a spinner shows and picking stops |
disabled | boolean | false | Whether the upload is disabled |
invalid | boolean | false | Whether validation failed |
class | string | — | Classes appended to the root element |
Slots
| Slot | Description |
|---|---|
| default | The text inside the drop area or button |
icon | The icon inside the drop area or button |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: File | File[] | null | The chosen files changed |
reject | rejections: FileUploadRejection[] | Files were rejected, each with its file and reason |
The reason of a FileUploadRejection is 'type', 'size' or 'count'; the type is exported from the package entry.