rapidUI

File Input

Single file or image input with preview, drag-and-drop and size validation.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/file-input.json

Installs to @/components/ui/file-input.

Manual installation

Install the dependencies:

pnpm add lucide-react@^1.48.0

Add the components it builds on: button.

Copy the source into your project:

components/ui/file-input.tsx
'use client'

// NOTE: UI strings are hardcoded in English. This is a template component — wire it to your app's i18n when adopting it.
import { Trash2Icon, UploadIcon } from 'lucide-react'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

interface FileInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value'> {
	initialImage?: string
	value?: File | null
	onImageChange?: (file: File | null) => void
	readOnly?: boolean
	disabled?: boolean
	/**
	 * Set on the button that opens the file picker, so a `<label htmlFor>` or `FieldLabel` can point at it. The
	 * `aria-label`, `aria-labelledby`, `aria-describedby` and `aria-invalid` props go to the same button.
	 */
	id?: string
	'aria-invalid'?: boolean | 'true' | 'false'
	/** Maximum file size in bytes */
	maxSize?: number
}

export function FileInput({
	initialImage,
	value,
	onImageChange,
	readOnly = false,
	disabled = false,
	accept = 'image/*',
	id,
	'aria-label': ariaLabel,
	'aria-labelledby': ariaLabelledBy,
	'aria-describedby': ariaDescribedBy,
	'aria-invalid': ariaInvalid,
	maxSize,
}: FileInputProps = {}) {
	const ref = React.useRef<HTMLInputElement>(null)
	const errorId = React.useId()
	const [preview, setPreview] = useState<string | null>(initialImage || null)
	const [file, setFile] = useState<File | null>(value || null)
	const [isDragging, setIsDragging] = useState(false)
	const [sizeError, setSizeError] = useState<string | null>(null)

	useEffect(() => {
		if (initialImage) {
			setPreview(initialImage)
		}
	}, [initialImage])

	const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
		const selectedFile = event.target.files?.[0]
		processFile(selectedFile)
	}

	const processFile = (selectedFile: File | undefined) => {
		if (selectedFile) {
			if (selectedFile.size === 0) {
				setSizeError('File is empty. Please choose a file with content.')
				return
			}

			// Check file size if maxSize is specified
			if (maxSize && selectedFile.size > maxSize) {
				const maxSizeKB = Math.round(maxSize / 1024)
				setSizeError(`File is too large. Maximum size: ${maxSizeKB} KB`)
				return
			}

			setSizeError(null)
			setFile(selectedFile)
			const reader = new FileReader()
			reader.onloadend = () => {
				setPreview(reader.result as string)
			}

			reader.readAsDataURL(selectedFile)

			onImageChange?.(selectedFile)
		}
	}

	const handleRemove = () => {
		setFile(null)
		setPreview(null)
		onImageChange?.(null)
		if (ref.current) {
			ref.current.value = ''
		}
	}

	const handleDragOver = (e: React.DragEvent) => {
		e.preventDefault()
		if (!readOnly && !disabled) {
			setIsDragging(true)
		}
	}

	const handleDragLeave = (e: React.DragEvent) => {
		e.preventDefault()
		setIsDragging(false)
	}

	const handleDrop = (e: React.DragEvent) => {
		e.preventDefault()
		setIsDragging(false)
		if (!readOnly && !disabled) {
			const droppedFile = e.dataTransfer.files?.[0]
			processFile(droppedFile)
		}
	}

	const isDisabledOrReadOnly = disabled || readOnly

	// The file input itself is hidden, so the label and description go to the button that opens the picker.
	const controlProps = {
		id,
		'aria-label': ariaLabel,
		'aria-labelledby': ariaLabelledBy,
		'aria-describedby': [ariaDescribedBy, sizeError ? errorId : undefined].filter(Boolean).join(' ') || undefined,
		'aria-invalid': ariaInvalid,
	}

	return (
		<div className="space-y-2">
			{/* Hidden file input */}
			<input ref={ref} type="file" onChange={handleFileChange} accept={accept} disabled={disabled} className="hidden" />
			{sizeError && (
				<p id={errorId} className="text-sm text-destructive">
					{sizeError}
				</p>
			)}

			{!file && !preview ? (
				<button
					type="button"
					onClick={() => ref.current?.click()}
					onDragOver={handleDragOver}
					onDragLeave={handleDragLeave}
					onDrop={handleDrop}
					disabled={isDisabledOrReadOnly}
					{...controlProps}
					data-dragging={isDragging || undefined}
					className={cn(
						'flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-input bg-background px-6 py-4 text-sm text-muted-foreground transition-colors outline-none',
						'hover:border-primary hover:bg-primary/5 data-dragging:border-primary data-dragging:bg-primary/5',
						'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring',
						'aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:hover:border-destructive dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
						'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
					)}
				>
					<UploadIcon className="size-4" />
					<span>Choose file or drag here</span>
				</button>
			) : (
				<div className="flex items-center gap-3">
					<span className="sr-only">Preview</span>
					{preview && (
						<button
							type="button"
							onClick={() => ref.current?.click()}
							disabled={isDisabledOrReadOnly}
							{...controlProps}
							className={cn(
								'group relative rounded-lg border border-border bg-muted/50 p-2 transition-colors outline-none',
								!isDisabledOrReadOnly && 'cursor-pointer hover:border-primary hover:bg-muted',
								'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring',
								'aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
								'disabled:cursor-not-allowed',
							)}
						>
							<img src={preview} alt="Preview" className="h-16 w-auto object-contain" />
							{!isDisabledOrReadOnly && (
								<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
									<UploadIcon className="size-4 text-white" />
								</div>
							)}
						</button>
					)}
					{!isDisabledOrReadOnly && (
						<Button
							type="button"
							variant="ghost"
							size="icon"
							onClick={handleRemove}
							className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive active:bg-destructive/20 dark:hover:bg-destructive/10"
						>
							<Trash2Icon />
							<span className="sr-only">Remove</span>
						</Button>
					)}
				</div>
			)}
		</div>
	)
}

A single-file input aimed at images: click or drop to pick a file, see a thumbnail preview, remove it again. Rejects empty files and files above maxSize with an inline error.

i18n: the visible strings ("Choose file or drag here", error messages, …) are hardcoded in English. This is a template component — replace them with your app's translation layer after installing.

Usage

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { FileInput } from "@/components/ui/file-input"

const [logo, setLogo] = useState<File | null>(null)

<Field>
  <FieldLabel htmlFor="tenant-logo">Logo</FieldLabel>
  <FileInput
    id="tenant-logo"
    aria-describedby="tenant-logo-description"
    value={logo}
    onImageChange={setLogo}
    initialImage={tenant.logoUrl}
    accept="image/png,image/svg+xml"
    maxSize={2 * 1024 * 1024}
  />
  <FieldDescription id="tenant-logo-description">PNG or SVG up to 2 MB.</FieldDescription>
</Field>

Examples

Default

The input has no label of its own. Here it sits under a FieldTitle with a FieldDescription that explains what to upload; see With label for a label that is tied to the control.

With label

Pass id and point a FieldLabel (or any <label htmlFor>) at it. The id goes to the button that opens the file picker, so the label names the button and a click on the label opens the picker. aria-describedby ties the description to the same button.

With initial image

initialImage previews a file that is already stored, here the coat of arms shown on the e-service portal. Click the thumbnail to replace it or the trash button to remove it.

With max size

maxSize is in bytes. Larger files are rejected with an inline error and the previous value is kept.

Controlled

Pass value and onImageChange to lift the file into your form state.

Invalid

aria-invalid gives the button the error style, and aria-describedby points at the FieldError.

Read-only

readOnly shows the preview without the replace and remove actions.

Disabled

Accessibility

  • The native file input is hidden; the focusable control is the button that opens the file picker (or the thumbnail button once a file is chosen). id, aria-label, aria-labelledby, aria-describedby and aria-invalid are forwarded to that button
  • Give the control a name with <FieldLabel htmlFor={id}>, or with aria-labelledby when the label is not a label element
  • The built-in error for an empty or too large file is added to the button's aria-describedby, next to the ids you pass
  • The remove button has the accessible name "Remove"
  • File Dropzone — multiple files
  • Input