Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/file-input.jsonInstalls to @/components/ui/file-input.
Manual installation
Install the dependencies:
pnpm add lucide-react@^1.48.0Add the components it builds on: button.
Copy the source into your project:
'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-describedbyandaria-invalidare forwarded to that button - Give the control a name with
<FieldLabel htmlFor={id}>, or witharia-labelledbywhen the label is not alabelelement - 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"
Related Components
- File Dropzone — multiple files
- Input