Skip to content

File Input

A modern file upload component with drag-and-drop support, file list management, constraint filtering, and full form integration. Shares the same visual theme system as bit-input.

Features

  • 🌈 6 Semantic Colors — primary, secondary, info, success, warning, error
  • 🎨 5 Variants — solid, flat, bordered, outline, ghost
  • 📎 Click to Browse — opens the native file picker on click or keyboard activation
  • 📏 3 Sizes — sm, md, lg
  • 🔒 Constraintsaccept, max-size, max-files filtering built-in
  • 🔗 Form-Associated — participates in native form submission via FormData
  • 🔲 Multiple Selection — toggle via multiple attribute
  • 🖱️ Drag & Drop — drop files directly onto the dropzone
  • 🗂️ File List — displays selected files with name, size, and individual remove buttons

Source Code

View Source Code
ts
import {
  computed,
  createId,
  define,
  defineField,
  html,
  inject,
  on,
  onCleanup,
  ref,
  signal,
  onMounted,
} from '@vielzeug/craftit';
import { createPressControl } from '@vielzeug/craftit/controls';
import { createDropZone } from '@vielzeug/dragit';

import { disabledLoadingMixin, forcedColorsFocusMixin, formFieldMixins, sizeVariantMixin } from '../../styles';
import { FILE_INPUT_SIZE_PRESET } from '../shared/design-presets';
import { mountFormContextSync } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import componentStyles from './file-input.css?inline';

const formatBytes = (bytes: number) => {
  if (bytes === 0) return '0 B';

  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));

  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};

const isFileAccepted = (file: File, accept: string) => {
  if (!accept || accept === '*') return true;

  const types = accept.split(',').map((t) => t.trim().toLowerCase());
  const fileName = file.name.toLowerCase();
  const fileType = file.type.toLowerCase();

  return types.some((type) => {
    if (type.startsWith('.')) return fileName.endsWith(type);

    if (type.endsWith('/*')) return fileType.startsWith(type.replace('/*', '/'));

    return fileType === type;
  });
};

const isFileSizeAllowed = (file: File, maxSize?: number) => !maxSize || file.size <= maxSize;

/** File input component properties */
export type BitFileInputProps = {
  /** Accepted file types (comma-separated, e.g. '.jpg, .png, image/*') */
  accept?: string;
  /** Theme color tint */
  color?: string;
  /** Disabled state */
  disabled?: boolean;
  /** Error message text */
  error?: string;
  /** Helper text displayed below the input */
  helper?: string;
  /** Input label text */
  label?: string;
  /** Max number of files allowed (only used if multiple is true) */
  'max-files'?: number;
  /** Max size of a single file in bytes */
  'max-size'?: number;
  /** Allow multiple files selection */
  multiple?: boolean;
  /** Form field name */
  name?: string;
  /** Required field */
  required?: boolean;
  /** Field size preset */
  size?: string;
};

/** Events emitted by the file-input component */
export type BitFileInputEvents = {
  /** Emitted when files are added or removed */
  change: { files: File[]; originalEvent?: Event; value: File[] };
  /** Emitted when a specific file is removed */
  remove: { file: File; files: File[]; originalEvent?: Event; value: File[] };
};

/**
 * A file upload field with drag-and-drop support and built-in validation messaging.
 *
 * @element bit-file-input
 *
 * @attr {string} accept - Comma-separated file extensions or MIME types
 * @attr {boolean} multiple - Enable multiple files selection
 * @attr {number} max-files - Max number of files allowed
 * @attr {number} max-size - Max size of each file in bytes
 * @attr {boolean} disabled - Disable interaction
 * @attr {string} error - Show an error state/message
 * @attr {string} helper - Provide helper context below the dropzone
 *
 * @fires change - detail: { files: File[], value: File[] }
 * @fires remove - detail: { file: File, files: File[] }
 *
 * @cssprop --border - Border token for the file field and dropzone
 * @cssprop --border-2 - Stronger border token used for hover and focus states
 * @cssprop --color-canvas - Base background for the file input surface
 * @cssprop --color-contrast-100 - Hover background for the dropzone and file list
 * @cssprop --color-contrast-200 - Divider and border contrast color
 * @cssprop --color-contrast-300 - Muted contrast tone for secondary details
 * @cssprop --color-contrast-400 - Secondary text color for helper content
 * @cssprop --color-contrast-50 - Soft background used for the dropzone surface
 * @cssprop --color-contrast-500 - Primary body text color in the field
 * @cssprop --color-contrast-700 - Strong text color for labels and filenames
 * @cssprop --color-error - Error accent color for invalid and rejected states
 * @cssprop --color-error-backdrop - Error tint used behind invalid dropzone states
 * @part wrapper - Root wrapper around the file input field
 * @part label - Visible label rendered above the dropzone
 * @part dropzone - Interactive drag-and-drop target
 * @part input - Native file input element
 * @part helper - Helper text shown beneath the dropzone
 * @part error - Error message shown beneath the field
 * @example
 * ```html
 * <bit-file-input label="Upload files" accept="image/*" multiple />
 * <bit-file-input label="Resume" accept=".pdf,.doc,.docx" max-size="5242880" />
 * <bit-file-input variant="bordered" color="primary" />
 * ```
 */
export const FILE_INPUT_TAG = define<BitFileInputProps, BitFileInputEvents>('bit-file-input', {
  formAssociated: true,
  props: {
    accept: undefined,
    color: undefined,
    disabled: false,
    error: undefined,
    helper: undefined,
    label: undefined,
    'max-files': 0,
    'max-size': 0,
    multiple: false,
    name: undefined,
    required: false,
    size: undefined,
  },
  setup(props, { emit, host }) {
    // ============================================
    // State
    // ============================================

    const files = signal<File[]>([]);
    const isDragging = signal(false);
    const formCtx = inject(FORM_CTX);
    const isDisabled = computed(() => Boolean(props.disabled.value) || Boolean(formCtx?.disabled.value));
    const maxFilesLimit = computed(() => props['max-files'].value ?? 0);
    const maxSizeLimit = computed(() => props['max-size'].value ?? 0);

    // ============================================
    // Form Integration
    // ============================================

    defineField({
      disabled: isDisabled,
      toFormValue: (fi: File[]) => {
        if (fi.length === 0) return null;

        const name = props.name.value || 'file';
        const fd = new FormData();

        for (const file of fi) fd.append(name, file);

        return fd;
      },
      value: files,
    });

    // Sync host attributes for CSS selectors
    const isInvalid = computed(() => Boolean(props.error.value));

    host.bind({
      attr: {
        'drag-over': () => (isDragging.value ? true : undefined),
        invalid: () => (isInvalid.value ? true : undefined),
      },
    });

    mountFormContextSync(host.el, formCtx, props);

    // ============================================
    // IDs
    // ============================================
    const fileInputId = createId('file-input');
    const labelId = `label-${fileInputId}`;
    const helperId = `helper-${fileInputId}`;
    const errorId = `error-${fileInputId}`;

    // ============================================
    // Refs
    // ============================================
    const dropzoneRef = ref<HTMLDivElement>();
    const inputRef = ref<HTMLInputElement>();
    const hintText = computed(() => {
      const parts: string[] = [];

      if (props.accept.value) {
        parts.push(
          props.accept.value
            .split(',')
            .map((s: string) => s.trim())
            .join(', '),
        );
      }

      const maxSize = maxSizeLimit.value;

      if (maxSize > 0) parts.push(`max ${formatBytes(maxSize)}`);

      const maxFiles = maxFilesLimit.value;

      if (maxFiles > 0) parts.push(`up to ${maxFiles} file${maxFiles !== 1 ? 's' : ''}`);

      return parts.join(' · ');
    });

    // ============================================
    // File Management
    // ============================================
    function addFiles(newFiles: File[], originalEvent?: Event): void {
      if (isDisabled.value) return;

      const acceptVal = props.accept.value;
      const isMultiple = Boolean(props.multiple.value);
      let incoming = Array.from(newFiles);

      if (!isMultiple) incoming = incoming.slice(0, 1);

      incoming = incoming.filter((f) => isFileAccepted(f, acceptVal || '') && isFileSizeAllowed(f, maxSizeLimit.value));

      let updated: File[] = isMultiple ? [...files.value] : [];

      for (const f of incoming) {
        if (!updated.includes(f)) updated.push(f);
      }

      if (maxFilesLimit.value > 0 && updated.length > maxFilesLimit.value) {
        updated = updated.slice(0, maxFilesLimit.value);
      }

      files.value = updated;
      emit('change', { files: files.value, originalEvent, value: files.value });
    }
    function removeFile(file: File, originalEvent?: Event): void {
      files.value = files.value.filter((f) => f !== file);
      emit('remove', { file, files: files.value, originalEvent, value: files.value });
      emit('change', { files: files.value, originalEvent, value: files.value });
    }

    // ============================================
    // Mount
    // ============================================
    // ============================================
    // Template
    // ============================================
    onMounted(() => {
      const inp = inputRef.value!;
      const dz = dropzoneRef.value!;
      let skipNextClick = false;
      const pressControl = createPressControl({
        disabled: () => isDisabled.value,
        onPress: () => {
          inp.click();
        },
      });

      // Native input → add files
      on(inp, 'change', (e: Event) => {
        const input = e.target as HTMLInputElement;

        if (input.files?.length) addFiles(Array.from(input.files), e);

        input.value = ''; // reset so the same file triggers change again
      });
      // Click dropzone → open file picker
      on(dz, 'click', (e: MouseEvent) => {
        if (e.target === inp) return;

        if (skipNextClick) {
          skipNextClick = false;

          return;
        }

        if (!isDisabled.value) inp.click();
      });
      // Keyboard: Enter / Space → open picker
      on(dz, 'keydown', (e: KeyboardEvent) => {
        skipNextClick = pressControl.handleKeydown(e) && e.key === 'Enter';
      });

      const dropZone = createDropZone({
        disabled: () => isDisabled.value,
        element: dz,
        onDrop: (droppedFiles, e) => addFiles(droppedFiles, e),
        onHoverChange: (hovered) => {
          isDragging.value = hovered;
        },
      });

      onCleanup(() => dropZone.destroy());
    });

    return () => html`
      <div class="file-input-wrapper" part="wrapper">
        <label class="label-outside" id="${labelId}" part="label" ?hidden=${() => !props.label.value}
          >${props.label}</label
        >
        <div
          class="dropzone"
          part="dropzone"
          ref=${dropzoneRef}
          role="button"
          :tabindex="${() => (isDisabled.value ? '-1' : '0')}"
          :aria-disabled="${() => String(isDisabled.value)}"
          :aria-label="${() => (!props.label.value ? 'File upload drop zone' : null)}"
          :aria-labelledby="${() => (props.label.value ? labelId : null)}"
          aria-describedby="${helperId}">
          <input
            type="file"
            ref=${inputRef}
            part="input"
            id="${fileInputId}"
            :accept="${props.accept}"
            ?multiple="${props.multiple}"
            ?required="${props.required}"
            ?disabled="${isDisabled}"
            :name="${props.name}"
            hidden
            inert
            tabindex="-1" />
          <div class="dropzone-content">
            <span class="dropzone-icon" aria-hidden="true">
              <bit-icon name="upload" size="36" stroke-width="1.5" aria-hidden="true"></bit-icon>
            </span>
            <span class="dropzone-title">Drop files here or <u>click to browse</u></span>
            <span class="dropzone-hint" ?hidden=${() => !hintText.value}>${hintText}</span>
          </div>
        </div>
        <ul class="file-list" role="list" aria-label="Selected files" ?hidden=${() => files.value.length === 0}>
          ${() =>
            files.value.map(
              (file: File) => html`
                <li class="file-item">
                  <span class="file-icon" aria-hidden="true">
                    <bit-icon name="file" size="18" stroke-width="1.75" aria-hidden="true"></bit-icon>
                  </span>
                  <span class="file-meta">
                    <span class="file-name" title="${file.name}">${file.name}</span>
                    <span class="file-size">${formatBytes(file.size)}</span>
                  </span>
                  <button
                    class="file-remove"
                    type="button"
                    aria-label="${`Remove ${file.name}`}"
                    @click=${(e: Event) => removeFile(file, e)}>
                    <bit-icon name="x" size="12" stroke-width="2.5" aria-hidden="true"></bit-icon>
                  </button>
                </li>
              `,
            )}
        </ul>
        <div class="helper-text" id="${helperId}" part="helper" ?hidden=${() => isInvalid.value || !props.helper.value}>
          ${props.helper}
        </div>
        <div
          class="helper-text helper-text-error"
          id="${errorId}"
          role="alert"
          part="error"
          ?hidden=${() => !isInvalid.value}>
          ${() => props.error.value ?? ''}
        </div>
      </div>
    `;
  },
  shadow: { delegatesFocus: true },
  styles: [
    ...formFieldMixins,
    sizeVariantMixin(FILE_INPUT_SIZE_PRESET),
    disabledLoadingMixin(),
    forcedColorsFocusMixin('.dropzone'),
    componentStyles,
  ],
});

Basic Usage

html
<bit-file-input label="Upload files"></bit-file-input>

<script type="module">
  import '@vielzeug/buildit/file-input';
</script>

Visual Options

Variants

Six visual variants for different UI contexts and levels of emphasis.

PreviewCode
RTL

Colors

Six semantic colors for different contexts and validation states.

PreviewCode
RTL

Sizes

Three sizes for different contexts.

PreviewCode
RTL

Rounded (Custom Border Radius)

Use the rounded attribute to apply a border radius from the theme.

PreviewCode
RTL

Customization

Multiple Files

Enable multi-file selection with the multiple attribute.

PreviewCode
RTL

Accept Filter

Restrict accepted file types using MIME types or file extensions. The accepted types are shown in the dropzone hint automatically.

PreviewCode
RTL

File Size & Count Limits

Use max-size (bytes) and max-files to enforce constraints. Files that don't meet the criteria are silently filtered out. The limits appear in the dropzone hint.

PreviewCode
RTL

With Helper Text

Provide context below the dropzone using the helper attribute.

PreviewCode
RTL

Full Width

Expand the component to fill its container with fullwidth.

PreviewCode
RTL

States

Disabled

Prevents all interaction — click, drag-and-drop, and keyboard activation are all blocked.

PreviewCode
RTL

Error State

Display a validation error with the error attribute. The error message replaces the helper text.

PreviewCode
RTL

Form Integration

bit-file-input is a form-associated custom element. It serializes its selected files as FormData under the given name key — identical to how a native <input type="file"> behaves.

html
<form id="upload-form" method="post" enctype="multipart/form-data">
  <bit-file-input name="documents" multiple accept=".pdf,.docx" label="Upload documents" required> </bit-file-input>
  <button type="submit">Submit</button>
</form>

Listening to the change event for reactive scenarios:

js
const fileInput = document.querySelector('bit-file-input');

fileInput.addEventListener('change', ({ detail }) => {
  console.log('Selected files:', detail.files);
});

fileInput.addEventListener('remove', ({ detail }) => {
  console.log('Removed:', detail.file.name);
  console.log('Remaining:', detail.files);
});

API Reference

Attributes

AttributeTypeDefaultDescription
acceptstring''Accepted MIME types or extensions (comma-separated)
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Color theme
disabledbooleanfalseDisable all interaction
errorstring''Error message (replaces helper text)
fullwidthbooleanfalseExpand to full width
helperstring''Helper text below the dropzone
labelstring''Label text displayed above the dropzone
max-filesnumber0Maximum number of files (0 = unlimited)
max-sizenumber0Maximum file size in bytes (0 = unlimited)
multiplebooleanfalseAllow selecting multiple files
namestring''Form field name
requiredbooleanfalseMark as required
rounded'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'Border radius
size'sm' | 'md' | 'lg''md'Component size
variant'solid' | 'flat' | 'bordered' | 'outline' | 'ghost''solid'Visual variant

Events

EventDetailDescription
change{ files: File[], originalEvent?: Event }Emitted when selection changes (add or remove)
remove{ file: File, files: File[] }Emitted when a file is removed from the list

CSS Parts

PartDescription
wrapperThe outer wrapper <div>
labelThe <label> element above the dropzone
dropzoneThe interactive drag-and-drop zone
inputThe hidden native <input type="file">
helperThe helper text <div>
errorThe error text <div>

CSS Custom Properties

PropertyDescriptionDefault
--file-input-bgDropzone background colorvar(--color-contrast-100)
--file-input-border-colorDropzone border colorvar(--color-contrast-300)
--file-input-radiusBorder radiusvar(--rounded-lg)
--file-input-min-heightMinimum dropzone heightvar(--size-40)
--file-input-font-sizeFont sizevar(--text-sm)

Accessibility

The file input component follows WCAG 2.1 Level AA standards.

bit-file-input

Keyboard Navigation

  • Tab focuses the dropzone; Enter / Space open the native file picker.
  • Remove buttons inside the file list are individually focusable.

Screen Readers

  • The dropzone uses role="button" with aria-labelledby linking the label and aria-describedby linking helper text.
  • Each remove button has a descriptive aria-label (e.g. "Remove report.pdf").
  • Error messages use role="alert" for live-region announcements.
  • aria-disabled reflects the disabled state.

Best Practices

Do:

  • Always provide a label to clearly communicate what files are expected.
  • Use accept to guide users toward valid file types and avoid upload errors.
  • Set max-size and max-files to prevent oversized or unexpected uploads.
  • Use multiple only when your backend truly supports multiple files per field.
  • Pair with helper text to document accepted types and size limits.
  • Use semantic color values (success, error) to communicate validation state.

Don't:

  • Rely solely on client-side max-size / accept filtering — always validate on the server.
  • Omit a name attribute when using the component inside a <form> — it is required for form submission.