Keyboard shortcut: Ctrl + K
Get started

File input

File selection as a form field: a read-only text field over a hidden file input, which also accepts a drop. The value is always a list of files, whether or not several are allowed.

Usage

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'

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

<template>
  <VFileInput v-model="files" label="Attachment" />
</template>

Examples

Label and hint

label, hint and placeholder behave as on any other field. iconStart puts an icon at the start of the field, rendered before the chips rather than in their place.

vue

Drop a file on the field, or use the paperclip

contract.pdfannex.pdf

An icon at the start, the paperclip at the end

<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'
import { search } from 'vectis-ui/icons'

const attachment = ref<File[]>([])
const found = ref<File[]>([
  new File(['x'], 'contract.pdf', { type: 'application/pdf' }),
  new File(['x'], 'annex.pdf', { type: 'application/pdf' }),
])
</script>

<template>
  <div class="column">
    <VFileInput
      v-model="attachment"
      label="Attachment"
      hint="Drop a file on the field, or use the paperclip"
      placeholder="No file chosen yet"
    />

    <!-- The start icon is rendered before the chips, so a chip display keeps both. -->
    <VFileInput
      v-model="found"
      multiple
      display="chip"
      :icon-start="search"
      label="Search the attachments"
      hint="An icon at the start, the paperclip at the end"
    />
  </div>
</template>

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

Sizes

size sets the field height to 32, 40 or 48 pixels, and compact takes 4px off it. The chips of a chosen file sit one step below the field.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'

const rows = ref(
  (['sm', 'md', 'lg'] as const).flatMap((size) => [
    { key: size, size, compact: false, label: size, files: [] as File[] },
    { key: `${size}-compact`, size, compact: true, label: `${size}, compact`, files: [] as File[] },
  ]),
)
</script>

<template>
  <div class="column">
    <VFileInput
      v-for="row in rows"
      :key="row.key"
      v-model="row.files"
      :size="row.size"
      :compact="row.compact"
      :label="row.label"
      multiple
      display="chip"
    />
  </div>
</template>

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

Multiple files

multiple lets the field take several files. The model is a File array either way.

vue

Drop two and the second is turned away

The model is a File array either way, so nothing downstream has to branch

0 files
<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'

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

<template>
  <div class="column">
    <VFileInput
      v-model="one"
      label="One file, the default"
      hint="Drop two and the second is turned away"
    />

    <VFileInput
      v-model="several"
      multiple
      display="chip"
      counter
      label="Several files"
      hint="The model is a File array either way, so nothing downstream has to branch"
    />
  </div>
</template>

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

Clearable

clearable adds a cross that empties the whole selection at once.

vue

Add a file: the cross appears to the left of the paperclip

Files then come out one chip at a time

<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'

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

<template>
  <div class="column">
    <VFileInput
      v-model="withCross"
      clearable
      multiple
      display="chip"
      label="Clearable"
      hint="Add a file: the cross appears to the left of the paperclip"
    />

    <VFileInput
      v-model="withoutCross"
      multiple
      display="chip"
      label="Not clearable, the default"
      hint="Files then come out one chip at a time"
    />
  </div>
</template>

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

Display

display lists the files as names joined by commas, or as one dismissible chip each. The #chip slot replaces a chip and receives its shortened label, remove, and the size and density the field worked out.

vue

The names joined by commas, on one line

quarterly-report.pdfbalance-sh…l-v3.xlsx

One dismissible chip each, cut in the middle so the extension survives

quarterly-report.pdfbalance-sh…l-v3.xlsx
<script setup lang="ts">
import { ref } from 'vue'
import { VChip, VFileInput } from 'vectis-ui'
import { description, picture_as_pdf as pictureAsPdf } from 'vectis-ui/icons'

/* Two files to start with, so the three fields read before anything is picked. A real
   selection comes from the dialog or from a drop. */
const start = () => [
  new File(['x'], 'quarterly-report.pdf', { type: 'application/pdf' }),
  new File(['x'], 'balance-sheet-2026-final-v3.xlsx'),
]

const asText = ref(start())
const asChips = ref(start())
const custom = ref(start())
</script>

<template>
  <div class="column">
    <VFileInput
      v-model="asText"
      multiple
      label="text, the default"
      hint="The names joined by commas, on one line"
    />

    <VFileInput
      v-model="asChips"
      multiple
      display="chip"
      label="chip"
      hint="One dismissible chip each, cut in the middle so the extension survives"
    />

    <VFileInput v-model="custom" multiple display="chip" label="a chip of your own">
      <template #chip="{ file, label, remove, size, compact }">
        <VChip
          :icon-start="file.type === 'application/pdf' ? pictureAsPdf : description"
          :size="size"
          :compact="compact"
          :dismiss-label="`Remove ${file.name}`"
          variant="outline"
          tone="accent"
          dismissible
          @dismiss="remove"
        >
          {{ label }}
        </VChip>
      </template>
    </VFileInput>
  </div>
</template>

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

Per-file limits

accept takes the browser syntax and filters the system dialog as well as a file dropped on the field. maxSize bounds one file. A refused file never enters the model, and reject fires once per file.

vue

Images and PDFs, 500 kB each at most

<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput, type FileRejection } from 'vectis-ui'

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

/* One event per file, so a batch drop is reported precisely rather than as a single
   "something went wrong". The wording is yours: the component never writes it. */
const reasons: Record<string, string> = {
  type: 'wrong kind of file',
  size: 'too big on its own',
  count: 'too many files',
  'total-size': 'too much altogether',
}

function onReject(rejection: FileRejection) {
  refused.value = [rejection, ...refused.value].slice(0, 4)
}
</script>

<template>
  <div class="column">
    <VFileInput
      v-model="files"
      accept="image/*,.pdf"
      :max-size="500_000"
      multiple
      display="chip"
      label="Receipts"
      hint="Images and PDFs, 500 kB each at most"
      @reject="onReject"
    />

    <output v-if="refused.length" class="refused" aria-label="Files turned away">
      <span v-for="item in refused" :key="item.file.name + item.reason">
        {{ item.file.name }}: {{ reasons[item.reason] }}
      </span>
    </output>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-3);
  max-inline-size: 26rem;
}
.refused {
  display: grid;
  gap: var(--vectis-space-1);
  font-size: var(--vectis-text-body-sm-size);
  color: var(--vectis-color-danger-text);
}
</style>

Selection limits

maxFiles and maxTotalSize bound the selection as a whole. Screening runs in a fixed order: type, then size, then count, then total size.

vue

Three files at most, 1 MB altogether

0 files
<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput, type FileRejection } from 'vectis-ui'

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

const reasons: Record<string, string> = {
  type: 'wrong kind of file',
  size: 'too big on its own',
  count: 'that would be a fourth file',
  'total-size': 'that would take the batch over 1 MB',
}

function onReject(rejection: FileRejection) {
  refused.value = [rejection, ...refused.value].slice(0, 4)
}
</script>

<template>
  <div class="column">
    <VFileInput
      v-model="files"
      :max-files="3"
      :max-total-size="1_000_000"
      multiple
      display="chip"
      counter
      clearable
      label="Invoices"
      hint="Three files at most, 1 MB altogether"
      @reject="onReject"
    />

    <output v-if="refused.length" class="refused" aria-label="Files turned away">
      <span v-for="item in refused" :key="item.file.name + item.reason">
        {{ item.file.name }}: {{ reasons[item.reason] }}
      </span>
    </output>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  gap: var(--vectis-space-3);
  max-inline-size: 26rem;
}
.refused {
  display: grid;
  gap: var(--vectis-space-1);
  font-size: var(--vectis-text-body-sm-size);
  color: var(--vectis-color-danger-text);
}
</style>

Counter

counter adds a line under the field saying how much has been chosen. The #counter slot replaces it and receives the count, the total in bytes and the sentence already built. Where the counter of VInput and VTextarea counts characters and takes no slot, this one counts files.

vue
quarterly-report.pdfcover-photo.jpg

It sits under the field, to the right of this line

2 files (336 kB)
quarterly-report.pdfcover-photo.jpg
2 of 5 · 336 kB
<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'

/* A real byte length, so the figure the counter prints is a real one. */
const start = () => [
  new File([new Uint8Array(240_000)], 'quarterly-report.pdf', { type: 'application/pdf' }),
  new File([new Uint8Array(96_000)], 'cover-photo.jpg', { type: 'image/jpeg' }),
]

const standard = ref(start())
const custom = ref(start())
</script>

<template>
  <div class="column">
    <VFileInput
      v-model="standard"
      counter
      multiple
      display="chip"
      label="The counter as it comes"
      hint="It sits under the field, to the right of this line"
    />

    <VFileInput v-model="custom" counter multiple display="chip" label="A wording of your own">
      <template #counter="{ count, bytes }">
        <span class="own">{{ count }} of 5 · {{ Math.round(bytes / 1000) }} kB</span>
      </template>
    </VFileInput>
  </div>
</template>

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

Custom icon

pickerIcon is the glyph at the end of the field that opens the system dialog, and it takes any icon value.

vue

pickerIcon takes the same values as every icon prop in the library

Naming the icon after what the field accepts says more than a paperclip

<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'
import { cloud_upload as cloudUpload, image } from 'vectis-ui/icons'

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

<template>
  <div class="column">
    <VFileInput
      v-model="documents"
      :picker-icon="cloudUpload"
      multiple
      display="chip"
      label="Documents"
      hint="pickerIcon takes the same values as every icon prop in the library"
    />

    <VFileInput
      v-model="photos"
      :picker-icon="image"
      accept="image/*"
      multiple
      display="chip"
      label="Photos"
      hint="Naming the icon after what the field accepts says more than a paperclip"
    />
  </div>
</template>

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

States

invalid marks the field as having an error. readonly keeps the selection on show and refuses every way of changing it. disabled greys the field out and takes it out of the tab order. noDrop turns dropping away alone, and loading is purely visual, a spinner replacing the attach icon. pickerIconLabel, clearLabel and loadingText rename what each of them announces.

vue

For a rule of your own: nothing here is checked by the browser

What was chosen stays on show, and no dialog, drop or removal will change it

Greyed through the colour tokens, and out of the tab order

Loading…

A spinner where the paperclip was, and nothing else changes

The paperclip still opens the dialog: only dropping is turned away

<script setup lang="ts">
import { ref } from 'vue'
import { VFileInput } from 'vectis-ui'

const chosen = () => [new File(['x'], 'contract.pdf', { type: 'application/pdf' })]

const invalid = ref(chosen())
const readOnly = ref(chosen())
const disabled = ref(chosen())
const noDrop = ref<File[]>([])
const uploading = ref(chosen())
</script>

<template>
  <div class="column">
    <VFileInput
      v-model="invalid"
      invalid
      display="chip"
      label="Invalid"
      hint="For a rule of your own: nothing here is checked by the browser"
    />

    <VFileInput
      v-model="readOnly"
      readonly
      display="chip"
      label="Read-only"
      hint="What was chosen stays on show, and no dialog, drop or removal will change it"
    />

    <VFileInput
      v-model="disabled"
      disabled
      display="chip"
      label="Disabled"
      hint="Greyed through the colour tokens, and out of the tab order"
    />

    <!-- Purely visual: files can still be dropped and the dialog still opens. -->
    <VFileInput
      v-model="uploading"
      loading
      display="chip"
      label="Uploading"
      hint="A spinner where the paperclip was, and nothing else changes"
    />

    <VFileInput
      v-model="noDrop"
      no-drop
      multiple
      display="chip"
      label="Drop refused"
      hint="The paperclip still opens the dialog: only dropping is turned away"
    />
  </div>
</template>

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

API

Props

PropTypeDefault
multiplebooleanfalse
Allows several files to be chosen. 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, and it has to be: 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.
displayFileInputDisplay'chip' | 'text''text'
How the chosen files are shown: their names joined by commas, or one dismissible chip each. It only means something when several files are allowed; a single name is always text.
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 chosen at most.
counterbooleanfalse
Shows how much has been chosen under the field, "3 files (1.2 MB)".
pickerIconIconSourceattach_file
The icon at the end of the field, which opens the file dialog.
noDropbooleanfalse
Refuses files dropped onto the component: only the dialog then adds any.
sizeFileInputSize'sm' | 'md' | 'lg''md'
The height of the field: 32, 40 or 48 pixels.
compactbooleanfalse
Takes 4px off the height, leaving the padding, the text and the icons as they are.
disabledbooleanfalse
Makes the field unusable, greyed out through the colour tokens.
readonlybooleanfalse
Shows what was chosen without allowing it to change: no dialog, no drop, no removal.
invalidbooleanfalse
Marks the field as invalid, for a rule of your own, since nothing here is checked by the browser.
labelstringnone
The label above the field, tied to it so that clicking it focuses the field.
hintstringnone
A line of help under the field, to the left of the counter. It is tied to the field for assistive technology.
placeholderstringnone
What the field says while nothing is chosen. It falls back to the design system dictionary.
iconStartIconSourcenone
An icon inside the field, at the start. It is rendered before the chips rather than in their place. Decorative until a @click:icon-start listener turns it into a button.
iconStartLabelstringnone
What the start icon does, in words, once it is clickable.
pickerIconLabelstringnone
What the end icon does, in words. It names the button that opens the file dialog, and falls back to the design system dictionary.
loadingbooleanfalse
Shows a spinner in place of the attach icon, while an upload is under way. It 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.
clearablebooleanfalse
Offers a cross that empties the selection. Worth turning on here more than on an ordinary field: what a picker holds cannot be erased by typing, so the cross is the only way back out of a wrong choice.
clearLabelstringnone
What that cross does, in words. 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
click:icon-start[event: MouseEvent]
The start icon was clicked. Attaching this listener is what turns that icon into a real button, which then needs iconStartLabel.
clear[]
The clear cross was pressed. The selection is already empty.
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: its kind, its size, or how many there already were.
remove[file: File, index: number]
One file was taken out through its chip, with the file and the position it held. change follows it with the whole list.

Slots

SlotType
start{}
Content at the start of the field, rendered after iconStart rather than in its place.
value-end{}
Controls of your own inside the field, placed before the ones the field owns: the clear cross and the icon that opens the panel. Those two are the component own affordance, which is why there is no end slot here.
chipFileInputChipSlotProps
Replaces the chip standing for one file. It receives the name already shortened in the middle so that its extension survives, remove, without which the file could no longer be taken out, and the size and density worked out to sit inside the field.
counterFileInputCounterSlotProps
Replaces the counter under the field. text is the sentence already built and translated; the count and the total size in bytes are there for a wording of your own.

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 type ChipSize = 'xs' | 'sm'
export interface FileInputChipSlotProps {
  file: File
  index: number
  label: string
  remove: () => void
  size: ChipSize
  compact: boolean
}
export interface FileInputCounterSlotProps {
  count: number
  bytes: number
  text: string
}
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