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
- 🔒 Constraints —
accept,max-size,max-filesfiltering built-in - 🔗 Form-Associated — participates in native form submission via
FormData - 🔲 Multiple Selection — toggle via
multipleattribute - 🖱️ 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
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
<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.
Colors
Six semantic colors for different contexts and validation states.
Sizes
Three sizes for different contexts.
Rounded (Custom Border Radius)
Use the rounded attribute to apply a border radius from the theme.
Customization
Multiple Files
Enable multi-file selection with the multiple attribute.
Accept Filter
Restrict accepted file types using MIME types or file extensions. The accepted types are shown in the dropzone hint automatically.
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.
With Helper Text
Provide context below the dropzone using the helper attribute.
Full Width
Expand the component to fill its container with fullwidth.
States
Disabled
Prevents all interaction — click, drag-and-drop, and keyboard activation are all blocked.
Error State
Display a validation error with the error attribute. The error message replaces the helper text.
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.
<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:
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
| Attribute | Type | Default | Description |
|---|---|---|---|
accept | string | '' | Accepted MIME types or extensions (comma-separated) |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Color theme |
disabled | boolean | false | Disable all interaction |
error | string | '' | Error message (replaces helper text) |
fullwidth | boolean | false | Expand to full width |
helper | string | '' | Helper text below the dropzone |
label | string | '' | Label text displayed above the dropzone |
max-files | number | 0 | Maximum number of files (0 = unlimited) |
max-size | number | 0 | Maximum file size in bytes (0 = unlimited) |
multiple | boolean | false | Allow selecting multiple files |
name | string | '' | Form field name |
required | boolean | false | Mark 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
| Event | Detail | Description |
|---|---|---|
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
| Part | Description |
|---|---|
wrapper | The outer wrapper <div> |
label | The <label> element above the dropzone |
dropzone | The interactive drag-and-drop zone |
input | The hidden native <input type="file"> |
helper | The helper text <div> |
error | The error text <div> |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--file-input-bg | Dropzone background color | var(--color-contrast-100) |
--file-input-border-color | Dropzone border color | var(--color-contrast-300) |
--file-input-radius | Border radius | var(--rounded-lg) |
--file-input-min-height | Minimum dropzone height | var(--size-40) |
--file-input-font-size | Font size | var(--text-sm) |
Accessibility
The file input component follows WCAG 2.1 Level AA standards.
bit-file-input
✅ Keyboard Navigation
Tabfocuses the dropzone;Enter/Spaceopen the native file picker.- Remove buttons inside the file list are individually focusable.
✅ Screen Readers
- The dropzone uses
role="button"witharia-labelledbylinking the label andaria-describedbylinking 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-disabledreflects the disabled state.
Best Practices
Do:
- Always provide a
labelto clearly communicate what files are expected. - Use
acceptto guide users toward valid file types and avoid upload errors. - Set
max-sizeandmax-filesto prevent oversized or unexpected uploads. - Use
multipleonly when your backend truly supports multiple files per field. - Pair with
helpertext to document accepted types and size limits. - Use semantic
colorvalues (success,error) to communicate validation state.
Don't:
- Rely solely on client-side
max-size/acceptfiltering — always validate on the server. - Omit a
nameattribute when using the component inside a<form>— it is required for form submission.