Keyboard shortcut: Ctrl + K
Get started

File picker

The drop zone sibling of VFileInput: a surface rather than a field, with the same screening rules and the same list of files as its value.

Usage

vue
Drag your files herePDF or PNG, up to 5 MBor
<script setup lang="ts">
import { ref } from 'vue'
import { VFilePicker } from 'vectis-ui'

const files = ref<File[]>([])
</script>

<template>
  <VFilePicker v-model="files" title="Drag your files here" subtitle="PDF or PNG, up to 5 MB" />
</template>

Examples

Title and subtitle

title is required and subtitle is where the rules go in plain words. Both have a slot, taking text and inline elements only.

vue
Drop your files hereor
Drop your files hereImages or PDF, up to 5 MB eachor
<script setup lang="ts">
import { ref } from 'vue'
import { VFilePicker } from 'vectis-ui'

const plain = ref<File[]>([])
const described = ref<File[]>([])
</script>

<template>
  <div class="column">
    <VFilePicker v-model="plain" title="Drop your files here" />

    <VFilePicker
      v-model="described"
      multiple
      accept="image/*,.pdf"
      :max-size="5_000_000"
      title="Drop your files here"
      subtitle="Images or PDF, up to 5 MB each"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

The list of files

preview says where the list of chosen files goes, under the zone or beside it, or removes it. Beside, it folds back underneath as soon as the component is narrow.

vue
Listed under the zoneWhat a narrow form wants, there being no room beside itor
Listed beside the zoneNarrow the window and it folds back underneathor
Not listed, the defaultTwo files are held all the same: the value never depends on this propor
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VFilePicker } from 'vectis-ui'

const under = ref<File[]>([])
const beside = ref<File[]>([])
const none = ref<File[]>([])

/* The same two files in all three, so the only thing that differs is where they are
   listed. They are built here rather than at setup: `File` is a browser type, and this
   page is rendered on the server before it ever reaches one. */
onMounted(() => {
  const seed = () => [
    new File([new Uint8Array(320_000)], 'contract.pdf', { type: 'application/pdf' }),
    new File([new Uint8Array(48_000)], 'figures.csv', { type: 'text/csv' }),
  ]
  under.value = seed()
  beside.value = seed()
  none.value = seed()
})
</script>

<template>
  <div class="column">
    <div class="narrow">
      <VFilePicker
        v-model="under"
        multiple
        preview="bottom"
        title="Listed under the zone"
        subtitle="What a narrow form wants, there being no room beside it"
      />
    </div>

    <!-- Left at the card's full width on purpose: the side list needs the room, and it
         folds back underneath as soon as the component itself is narrower than 34rem. -->
    <VFilePicker
      v-model="beside"
      multiple
      preview="end"
      title="Listed beside the zone"
      subtitle="Narrow the window and it folds back underneath"
    />

    <div class="narrow">
      <VFilePicker
        v-model="none"
        multiple
        title="Not listed, the default"
        subtitle="Two files are held all the same: the value never depends on this prop"
      />
    </div>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
}
.narrow {
  max-inline-size: 26rem;
}
</style>

Custom icons

icon is the large glyph at the top of the zone. typeIcons replaces the glyph a row shows for a kind of file, naming only the kinds you want to change, and removeIcon the button that takes a row out.

vue
Drop your sources hereor
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VFilePicker } from 'vectis-ui'
import { attach_file as attachFile, description } from 'vectis-ui/icons'

const files = ref<File[]>([])

/* The files are built here and not at setup: `File` is a browser type, and this page is
   rendered on the server before it ever reaches one. */
onMounted(() => {
  files.value = [
    new File([new Uint8Array(24_000)], 'styles.css', { type: 'text/css' }),
    new File([new Uint8Array(180_000)], 'assets.zip', { type: 'application/zip' }),
  ]
})
</script>

<template>
  <div class="column">
    <VFilePicker
      v-model="files"
      multiple
      preview="bottom"
      title="Drop your sources here"
      :icon="attachFile"
      :type-icons="{ code: description }"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

Thumbnails

An image is listed as itself, through a temporary address made in the page. hideThumbnails shows the icon for its kind instead.

vue
Thumbnails, the defaultAn image is shown as itselfor
hideThumbnailsEvery file shows its kind insteador
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VFilePicker } from 'vectis-ui'

const shown = ref<File[]>([])
const hidden = ref<File[]>([])

/* A real PNG, drawn in the browser: a thumbnail is only ever painted from bytes an
   <img> can decode, so an empty buffer would fall back to the kind icon and prove
   nothing. Both zones are given the same two files. */
async function gradientPng(name: string): Promise<File> {
  const canvas = document.createElement('canvas')
  canvas.width = 96
  canvas.height = 96
  const context = canvas.getContext('2d')
  if (!context) return new File([], name, { type: 'image/png' })
  const gradient = context.createLinearGradient(0, 0, 96, 96)
  gradient.addColorStop(0, '#6366f1')
  gradient.addColorStop(1, '#ec4899')
  context.fillStyle = gradient
  context.fillRect(0, 0, 96, 96)
  const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
  return new File(blob ? [blob] : [], name, { type: 'image/png' })
}

onMounted(async () => {
  const files = [
    await gradientPng('cover.png'),
    new File([new Uint8Array(90_000)], 'notes.pdf', { type: 'application/pdf' }),
  ]
  shown.value = files
  hidden.value = [...files]
})
</script>

<template>
  <div class="grid">
    <VFilePicker
      v-model="shown"
      multiple
      preview="bottom"
      title="Thumbnails, the default"
      subtitle="An image is shown as itself"
    />

    <VFilePicker
      v-model="hidden"
      multiple
      hide-thumbnails
      preview="bottom"
      title="hideThumbnails"
      subtitle="Every file shows its kind instead"
    />
  </div>
</template>

<style scoped>
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--vectis-space-5);
}
</style>

Multiple files

multiple lets the zone take several files, the extra ones being turned away otherwise. The value is a list either way, and reject fires once per refused file.

vue
One fileDrop two and the second is refusedor
As many as you likemultiple, with nothing else to bound itor
<script setup lang="ts">
import { ref } from 'vue'
import { VFilePicker, type FileRejection } from 'vectis-ui'

const one = ref<File[]>([])
const several = ref<File[]>([])

/* The component turns a file away and says so; showing why is the consumer's job, here
   and for every other limit. */
const refused = ref('')

function onReject({ file }: FileRejection) {
  refused.value = `${file.name} was turned away: this zone takes one file.`
}
</script>

<template>
  <div class="column">
    <VFilePicker
      v-model="one"
      preview="bottom"
      title="One file"
      subtitle="Drop two and the second is refused"
      @reject="onReject"
    />
    <p v-if="refused" class="value">{{ refused }}</p>

    <VFilePicker
      v-model="several"
      multiple
      preview="bottom"
      title="As many as you like"
      subtitle="multiple, with nothing else to bound it"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
.value {
  margin: 0;
  font-size: var(--vectis-text-body-sm-size);
  color: var(--vectis-color-danger-text);
}
</style>

Accepted kinds

accept takes the browser syntax and filters the system dialog as well as a dropped file. A file that fails it comes back through reject with the reason type.

vue
Images and PDFBrowse and the dialog offers nothing else; drop and the rule is applied againor
<script setup lang="ts">
import { ref } from 'vue'
import { VFilePicker, type FileRejection } from 'vectis-ui'

const files = ref<File[]>([])
const refused = ref('')

function onReject({ file }: FileRejection) {
  refused.value = `${file.name} was turned away: images and PDF only.`
}
</script>

<template>
  <div class="column">
    <VFilePicker
      v-model="files"
      multiple
      accept="image/*,.pdf"
      preview="bottom"
      title="Images and PDF"
      subtitle="Browse and the dialog offers nothing else; drop and the rule is applied again"
      @reject="onReject"
    />
    <p v-if="refused" class="value">{{ refused }}</p>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
.value {
  margin: 0;
  font-size: var(--vectis-text-body-sm-size);
  color: var(--vectis-color-danger-text);
}
</style>

Maximum size

maxSize is the largest one file may be, in bytes. Each file is weighed on its own.

vue
Up to 500 kB per fileEach file is weighed on its own, so a small one still gets throughor
<script setup lang="ts">
import { ref } from 'vue'
import { VFilePicker, type FileRejection } from 'vectis-ui'

const files = ref<File[]>([])
const refused = ref('')

function onReject({ file }: FileRejection) {
  refused.value = `${file.name} was turned away: 500 kB at most per file.`
}
</script>

<template>
  <div class="column">
    <VFilePicker
      v-model="files"
      multiple
      :max-size="500_000"
      preview="bottom"
      title="Up to 500 kB per file"
      subtitle="Each file is weighed on its own, so a small one still gets through"
      @reject="onReject"
    />
    <p v-if="refused" class="value">{{ refused }}</p>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
.value {
  margin: 0;
  font-size: var(--vectis-text-body-sm-size);
  color: var(--vectis-color-danger-text);
}
</style>

Total size and count

maxTotalSize and maxFiles bound the selection as a whole, counting what is already in the list. Screening runs in a fixed order: kind, then size, then count, then total size.

vue
1 MB in all, three files at mostWhat is already in the list counts towards the totalor
<script setup lang="ts">
import { ref } from 'vue'
import { VFilePicker, type FileRejection } from 'vectis-ui'

const files = ref<File[]>([])
const refused = ref('')

/* The reason says which rule was met, and the two here are not the same failure: one
   batch too heavy, or one file too many. */
function onReject({ file, reason }: FileRejection) {
  refused.value =
    reason === 'count'
      ? `${file.name} was turned away: three files at most.`
      : `${file.name} was turned away: 1 MB for the whole selection.`
}
</script>

<template>
  <div class="column">
    <VFilePicker
      v-model="files"
      multiple
      :max-total-size="1_000_000"
      :max-files="3"
      preview="bottom"
      title="1 MB in all, three files at most"
      subtitle="What is already in the list counts towards the total"
      @reject="onReject"
    />
    <p v-if="refused" class="value">{{ refused }}</p>
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
.value {
  margin: 0;
  font-size: var(--vectis-text-body-sm-size);
  color: var(--vectis-color-danger-text);
}
</style>

States

readonly shows what was taken and lets nothing change it, the remove buttons included. disabled greys the zone out and stops it accepting anything, mid-drag included.

vue
Read-onlyWhat was taken stays on show, its remove buttons disabled along with the zoneor
DisabledNo dialog, no drop, and the zone greys through the colour tokensor
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VFilePicker } from 'vectis-ui'

const taken = ref<File[]>([])
const empty = ref<File[]>([])

onMounted(() => {
  taken.value = [
    new File([new Uint8Array(320_000)], 'contract.pdf', { type: 'application/pdf' }),
    new File([new Uint8Array(48_000)], 'figures.csv', { type: 'text/csv' }),
  ]
})
</script>

<template>
  <div class="column">
    <VFilePicker
      v-model="taken"
      multiple
      readonly
      preview="bottom"
      title="Read-only"
      subtitle="What was taken stays on show, its remove buttons disabled along with the zone"
    />

    <VFilePicker
      v-model="empty"
      disabled
      title="Disabled"
      subtitle="No dialog, no drop, and the zone greys through the colour tokens"
    />
  </div>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  gap: var(--vectis-space-5);
  max-inline-size: 26rem;
}
</style>

API

Props

PropTypeDefault
titlestringnone
What the reader is being asked to drop, in one line. It is required: a drop zone with no instruction is just a rectangle. It shadows the HTML attribute of the same name, an accepted trade-off.
subtitlestringnone
A second line under it, for the constraints in plain words: kinds, sizes, how many.
iconIconSourcecloud_upload
The large icon at the top of the zone.
hideBrowsebooleanfalse
Hides the separator and the browse button under the instruction. That changes the nature of the zone: it then becomes the control itself, a real button, so Enter, Space and the focus come from the platform rather than from a container that merely reacts to clicks.
browseTextstringnone
The wording drawn on the browse button, which is also its accessible name. It falls back to the design system dictionary.
previewFilePickerPreviewfalse | 'bottom' | 'end'false
Where the files taken are listed: under the zone, or beside it, which folds back underneath when the component is narrow, following the width it was given rather than the width of the window. By default nothing is listed at all.
hideThumbnailsbooleanfalse
Shows the kind icon for every file in that list, images included, the way out when a list holds many images or very large ones. Left out, an image is shown as a thumbnail: it is given a temporary address, created in the browser only and released as soon as the file leaves the list or the component goes away.
typeIconsPartial<Record<FilePickerKind, IconSource>>none
Replaces the icon of one or more kinds of file.
removeIconIconSourceclose
The icon of the button removing a file from the list.
multiplebooleanfalse
Allows several files to be taken. With one only, every extra file is turned away.
acceptstringnone
Which kinds of file are accepted, in the browser's own syntax. It is applied twice: as an attribute, which filters the system's file dialog, and again in code, which is the only thing that can filter a dropped file.
maxSizenumbernone
The largest one file may be, in bytes.
maxTotalSizenumbernone
The largest the whole selection may be, in bytes.
maxFilesnumbernone
How many files may be taken at most.
disabledbooleanfalse
Makes the zone unusable, greyed out through the colour tokens.
readonlybooleanfalse
Shows what was taken without allowing it to change: no dialog, no drop, no removal. Its buttons stay reachable from the keyboard, announced as unavailable.
invalidbooleanfalse
Marks the zone as invalid, which colours its outline and is announced on the control the reader reaches. It is for a rule of your own: nothing here is checked by the browser, the real input being hidden.
loadingbooleanfalse
Shows a spinner in place of the zone icon, while an upload is under way typically. It says that something is happening and changes nothing else: files can still be dropped and the dialog still opens.
loadingTextstringnone
What screen readers announce while the spinner turns. It falls back to the design system dictionary.
v-modelFile[][]
Always a list of files, whether or not several are allowed, never a file on its own. The shape does not depend on a prop, so you never have to narrow a union TypeScript cannot discriminate.

Events

EventType
change[files: File[]]
The selection changed, with the whole list as it now stands.
reject[rejection: FileRejection]
A file was turned away, with which one and why.
remove[file: File, index: number]
A file was removed from the list, with which one and where it was.

Slots

SlotType
icon{}
The large icon, for an illustration the icon prop cannot express. It must stay non-interactive, and so must the two below: with the browse button hidden the zone is a button, and nothing interactive may sit inside one.
title{}
The instruction. Text and inline elements only, for the same reason.
subtitle{}
The second line. Same contract as the instruction.
browseFilePickerBrowseSlotProps
The browse button. Call the open it receives: without it a button of your own could no longer open the file dialog at all.
itemFilePickerRow
A whole row of the list, the way out for a row showing its own upload progress. It receives everything the standard row was given.
thumbnailFilePickerRow
The square at the start of a row alone: for a thumbnail produced by your server, a video's poster frame, or a format the browser cannot decode.
removeFilePickerRemoveSlotProps
The control that removes a row. remove is the only thing that can take the file out, and removeLabel is the ready-made accessible name, including the file's own, without which the button would be announced as nothing at all.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export interface BuiltinIcon {
  name: string
  paths: readonly [string] | readonly [string, string]
}
export interface FilePickerBrowseSlotProps {
  open: () => void
  disabled: boolean
}
export type FilePickerKind =
  'image' | 'pdf' | 'audio' | 'video' | 'archive' | 'spreadsheet' | 'code' | 'file'
export interface FilePickerRemoveSlotProps {
  file: File
  index: number
  remove: () => void
  removeLabel: string
}
export interface FilePickerRow {
  file: File
  index: number
  kind: FilePickerKind
  thumbnail: string | undefined
  icon: IconSource
  sizeText: string
  remove: () => void
}
export type FileRejectReason = 'type' | 'size' | 'count' | 'total-size'
export interface FileRejection {
  file: File
  reason: FileRejectReason
}
export type IconRender =
  | { path: string; viewBox?: string }
  | { component: Component; props?: Record<string, unknown> }
  | { src: string }
  | { text: string; class?: string }
  | { class: string }
export type IconSource = string | BuiltinIcon | IconRender

CSS variables

TokenValue
--vectis-control-size-file-picker-min-block10rem
--vectis-control-size-file-picker-icon2.5rem
--vectis-control-size-file-picker-thumb2.5rem