Preview file inputs
Five file inputs that show the selected file back to the user: a single image preview, a grid, a thumbnail list, a media list and a cover card. Picked images preview through an object URL that is revoked on cleanup, and the empty state paints a token placeholder rather than a remote image. Each is sized, token-driven and keyboard accessible.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/file-input-previewDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/file-input-preview.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { File as FileIcon, Film, Image as ImageIcon, Music, Upload, X } from "lucide-react"
import { cn } from "@/lib/utils"
/* Preview file-input family: 5 decorative pickers that show a preview of the
selected file. For real selected images URL.createObjectURL is used, and the URL
is ALWAYS released with URL.revokeObjectURL in the effect cleanup (otherwise
memory leaks). In the default state with no props it draws token-based
placeholder surfaces rather than a remote URL. Color comes ONLY from semantic
tokens (transparency via color-mix). The animations are disabled through
useReducedMotion. Deterministic (no Date.now or Math.random). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type FileInputProps = {
className?: string
size?: StyledSize
multiple?: boolean
accept?: string
onFilesChange?: (files: File[]) => void
}
function fileKey(file: File): string {
return `${file.name}:${file.size}:${file.lastModified}`
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${Math.round(bytes / (1024 * 1024))} MB`
}
function isImage(file: File): boolean {
return file.type.startsWith("image/")
}
/* Ortak dosya-state mantigi. */
function useFiles(multiple: boolean | undefined, onFilesChange?: (files: File[]) => void) {
const [files, setFiles] = React.useState<File[]>([])
const add = React.useCallback(
(incoming: FileList | File[] | null) => {
if (!incoming) return
const list = Array.from(incoming)
if (list.length === 0) return
setFiles((prev) => {
if (!multiple) {
const next = [list[0]]
onFilesChange?.(next)
return next
}
const map = new Map(prev.map((f) => [fileKey(f), f]))
for (const f of list) map.set(fileKey(f), f)
const next = Array.from(map.values())
onFilesChange?.(next)
return next
})
},
[multiple, onFilesChange]
)
const remove = React.useCallback(
(key: string) => {
setFiles((prev) => {
const next = prev.filter((f) => fileKey(f) !== key)
onFilesChange?.(next)
return next
})
},
[onFilesChange]
)
return { files, add, remove }
}
/* Object URL map for the selected images. Every URL is revoked on cleanup so no memory leaks. */
function useObjectUrls(files: File[]): Record<string, string> {
const [urls, setUrls] = React.useState<Record<string, string>>({})
const signature = files.map(fileKey).join("|")
React.useEffect(() => {
const created: string[] = []
const next: Record<string, string> = {}
for (const file of files) {
if (!isImage(file)) continue
const url = URL.createObjectURL(file)
created.push(url)
next[fileKey(file)] = url
}
setUrls(next)
return () => {
for (const url of created) URL.revokeObjectURL(url)
setUrls({})
}
// signature dosya listesini stabil sekilde temsil eder.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signature])
return urls
}
const iconSize: Record<StyledSize, string> = {
sm: "size-5",
md: "size-6",
lg: "size-8",
xl: "size-9",
}
const dropSurface: Record<StyledSize, string> = {
sm: "gap-2 rounded-lg p-4 text-sm",
md: "gap-3 rounded-xl p-6 text-sm",
lg: "gap-3 rounded-2xl p-8 text-base",
xl: "gap-4 rounded-2xl p-10 text-base",
}
const clearBtn =
"relative shrink-0 rounded-md text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 after:absolute after:-inset-1.5 motion-reduce:transition-none"
const surfaceBase =
"flex w-full flex-col items-center justify-center border border-dashed border-border bg-transparent text-center text-muted-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) hover:border-primary/60 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[drag-active]:border-primary data-[drag-active]:bg-[color-mix(in_oklab,var(--color-primary)_10%,transparent)] data-[drag-active]:text-foreground motion-reduce:transition-none [&>svg]:shrink-0 [&>i]:leading-none"
/* A token-based placeholder surface: no remote image, only a token texture. */
function PlaceholderSurface({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={cn(
"flex items-center justify-center bg-[linear-gradient(135deg,color-mix(in_oklab,var(--color-foreground)_8%,transparent),color-mix(in_oklab,var(--color-primary)_10%,transparent))] text-muted-foreground",
className
)}
>
<ImageIcon className="size-5" />
</span>
)
}
/* 1) ImagePreviewFile: tek gorsel secimi, buyuk onizleme paneli. */
export function ImagePreviewFile({ className, size = "md", accept = "image/*", onFilesChange }: FileInputProps) {
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [dragActive, setDragActive] = React.useState(false)
const { files, add, remove } = useFiles(false, onFilesChange)
const urls = useObjectUrls(files)
const current = files[0]
const url = current ? urls[fileKey(current)] : undefined
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose an image
</label>
<input
id={id}
ref={inputRef}
type="file"
accept={accept}
className="sr-only"
onChange={(e) => add(e.currentTarget.files)}
/>
{current ? (
<div className="relative overflow-hidden rounded-xl border border-border bg-surface-2">
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={current.name} className="h-48 w-full object-cover" />
) : (
<PlaceholderSurface className="h-48 w-full" />
)}
<div className="flex items-center gap-2 border-t border-border px-3 py-2 text-sm">
<FileIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">{current.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatSize(current.size)}</span>
<button
type="button"
aria-label={`Remove ${current.name}`}
onClick={() => remove(fileKey(current))}
className={clearBtn}
>
<X className="size-4" />
</button>
</div>
</div>
) : (
<button
type="button"
data-slot="styled-file-input-surface"
data-drag-active={dragActive || undefined}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragActive(false)
add(e.dataTransfer.files)
}}
className={cn(surfaceBase, dropSurface[size])}
>
<ImageIcon className={cn(iconSize[size], "text-primary")} />
<span className="font-medium text-foreground">Drop an image to preview it</span>
<span className="text-xs text-muted-foreground">PNG, JPG or GIF</span>
</button>
)}
</div>
)
}
/* 2) GridFile: cok dosyali secim, kare onizleme izgarasi. Gorsel olmayanlar
token placeholder ile temsil edilir. */
export function GridFile({ className, size = "md", multiple = true, accept = "image/*", onFilesChange }: FileInputProps) {
const reduce = useReducedMotion()
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [dragActive, setDragActive] = React.useState(false)
const { files, add, remove } = useFiles(multiple, onFilesChange)
const urls = useObjectUrls(files)
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose images
</label>
<input
id={id}
ref={inputRef}
type="file"
multiple={multiple}
accept={accept}
className="sr-only"
onChange={(e) => add(e.currentTarget.files)}
/>
<button
type="button"
data-slot="styled-file-input-surface"
data-drag-active={dragActive || undefined}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragActive(false)
add(e.dataTransfer.files)
}}
className={cn(surfaceBase, dropSurface[size])}
>
<Upload className={cn(iconSize[size], "text-primary")} />
<span className="font-medium text-foreground">Drop images to build a grid</span>
</button>
{files.length > 0 && (
<ul data-slot="styled-file-input-list" className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
<AnimatePresence initial={false}>
{files.map((file) => {
const url = urls[fileKey(file)]
return (
<motion.li
key={fileKey(file)}
layout={!reduce}
initial={reduce ? undefined : { opacity: 0, scale: 0.94 }}
animate={reduce ? undefined : { opacity: 1, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.94 }}
className="relative aspect-square overflow-hidden rounded-lg border border-border bg-surface-2"
>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={file.name} className="size-full object-cover" />
) : (
<PlaceholderSurface className="size-full" />
)}
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(fileKey(file))}
className="relative absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border bg-background text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 after:absolute after:-inset-1.5 motion-reduce:transition-none"
>
<X className="size-3" />
</button>
</motion.li>
)
})}
</AnimatePresence>
</ul>
)}
</div>
)
}
const thumbSize: Record<StyledSize, string> = {
sm: "size-8",
md: "size-10",
lg: "size-12",
xl: "size-14",
}
const rowSize: Record<StyledSize, string> = {
sm: "gap-2 rounded-md p-2 text-sm",
md: "gap-3 rounded-lg p-3 text-sm",
lg: "gap-3 rounded-lg p-4 text-base",
xl: "gap-4 rounded-xl p-5 text-base",
}
/* 3) ThumbFile: her satirda kucuk kare kucuk resim + ad + boyut. */
export function ThumbFile({ className, size = "md", multiple = true, accept, onFilesChange }: FileInputProps) {
const reduce = useReducedMotion()
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const { files, add, remove } = useFiles(multiple, onFilesChange)
const urls = useObjectUrls(files)
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose files
</label>
<input
id={id}
ref={inputRef}
type="file"
multiple={multiple}
accept={accept}
className="sr-only"
onChange={(e) => add(e.currentTarget.files)}
/>
<button
type="button"
data-slot="styled-file-input-surface"
onClick={() => inputRef.current?.click()}
className={cn(
"flex w-full items-center justify-center gap-2 border border-border bg-surface-2 font-medium text-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) hover:border-primary/60 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none [&>svg]:size-4 [&>svg]:shrink-0 [&>i]:text-base [&>i]:leading-none",
rowSize[size]
)}
>
<Upload />
Choose files
</button>
{files.length > 0 && (
<ul data-slot="styled-file-input-list" className="mt-3 flex flex-col gap-2">
<AnimatePresence initial={false}>
{files.map((file) => {
const url = urls[fileKey(file)]
return (
<motion.li
key={fileKey(file)}
layout={!reduce}
initial={reduce ? undefined : { opacity: 0, y: -4 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -4 }}
className="flex items-center gap-3 rounded-lg border border-border bg-surface-2 p-2 text-sm"
>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={file.name}
className={cn("shrink-0 rounded-md object-cover", thumbSize[size])}
/>
) : (
<PlaceholderSurface className={cn("shrink-0 rounded-md", thumbSize[size])} />
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-foreground">{file.name}</span>
<span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>
</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(fileKey(file))}
className={clearBtn}
>
<X className="size-4" />
</button>
</motion.li>
)
})}
</AnimatePresence>
</ul>
)}
</div>
)
}
/* Dosya turune gore ikon: gorsel / video / ses / diger. */
function KindIcon({ file, className }: { file: File; className?: string }) {
if (file.type.startsWith("video/")) return <Film className={className} />
if (file.type.startsWith("audio/")) return <Music className={className} />
if (file.type.startsWith("image/")) return <ImageIcon className={className} />
return <FileIcon className={className} />
}
/* 4) MediaFile: medya turunu (gorsel/video/ses) ayirt eden onizleme kartlari. */
export function MediaFile({ className, size = "md", multiple = true, accept, onFilesChange }: FileInputProps) {
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [dragActive, setDragActive] = React.useState(false)
const { files, add, remove } = useFiles(multiple, onFilesChange)
const urls = useObjectUrls(files)
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose media files
</label>
<input
id={id}
ref={inputRef}
type="file"
multiple={multiple}
accept={accept}
className="sr-only"
onChange={(e) => add(e.currentTarget.files)}
/>
<button
type="button"
data-slot="styled-file-input-surface"
data-drag-active={dragActive || undefined}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragActive(false)
add(e.dataTransfer.files)
}}
className={cn(surfaceBase, dropSurface[size])}
>
<Film className={cn(iconSize[size], "text-primary")} />
<span className="font-medium text-foreground">Drop media files</span>
<span className="text-xs text-muted-foreground">Images, video or audio</span>
</button>
{files.length > 0 && (
<ul data-slot="styled-file-input-list" className="mt-3 flex flex-col gap-2">
{files.map((file) => {
const url = urls[fileKey(file)]
return (
<li
key={fileKey(file)}
className="flex items-center gap-3 overflow-hidden rounded-lg border border-border bg-surface-2 p-2 text-sm"
>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={file.name} className="size-12 shrink-0 rounded-md object-cover" />
) : (
<span className="flex size-12 shrink-0 items-center justify-center rounded-md bg-[color-mix(in_oklab,var(--color-primary)_12%,transparent)] text-primary">
<KindIcon file={file} className="size-5" />
</span>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-foreground">{file.name}</span>
<span className="text-xs text-muted-foreground">
{file.type || "Unknown type"} - {formatSize(file.size)}
</span>
</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(fileKey(file))}
className={clearBtn}
>
<X className="size-4" />
</button>
</li>
)
})}
</ul>
)}
</div>
)
}
const cardSize: Record<StyledSize, string> = {
sm: "h-28",
md: "h-36",
lg: "h-44",
xl: "h-52",
}
/* 5) CardFile: tek dosyalik, kapak gorseli ustte olan onizleme karti. */
export function CardFile({ className, size = "md", accept, onFilesChange }: FileInputProps) {
const reduce = useReducedMotion()
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const [dragActive, setDragActive] = React.useState(false)
const { files, add, remove } = useFiles(false, onFilesChange)
const urls = useObjectUrls(files)
const current = files[0]
const url = current ? urls[fileKey(current)] : undefined
return (
<div
data-slot="styled-file-input"
data-drag-active={dragActive || undefined}
onDragOver={(e) => {
e.preventDefault()
setDragActive(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setDragActive(false)
}}
onDrop={(e) => {
e.preventDefault()
setDragActive(false)
add(e.dataTransfer.files)
}}
className={cn(
"w-full overflow-hidden rounded-xl border border-border bg-surface-2 transition-colors duration-(--motion-base) ease-(--motion-ease) data-[drag-active]:border-primary motion-reduce:transition-none",
className
)}
>
<label htmlFor={id} className="sr-only">
Choose a file
</label>
<input
id={id}
ref={inputRef}
type="file"
accept={accept}
className="sr-only"
onChange={(e) => add(e.currentTarget.files)}
/>
<motion.div
initial={false}
animate={reduce ? undefined : { opacity: 1 }}
className={cn("w-full overflow-hidden", cardSize[size])}
>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={current?.name ?? "Selected file"} className="size-full object-cover" />
) : (
<PlaceholderSurface className="size-full" />
)}
</motion.div>
<div className="flex items-center gap-3 border-t border-border px-3 py-3">
<span className="flex min-w-0 flex-1 flex-col text-sm">
<span className="truncate font-medium text-foreground">
{current ? current.name : "No file chosen"}
</span>
<span className="text-xs text-muted-foreground">
{current ? formatSize(current.size) : "Drop a file or pick one"}
</span>
</span>
{current ? (
<button
type="button"
aria-label={`Remove ${current.name}`}
onClick={() => remove(fileKey(current))}
className={clearBtn}
>
<X className="size-4" />
</button>
) : null}
<button
type="button"
data-slot="styled-file-input-surface"
onClick={() => inputRef.current?.click()}
className="inline-flex h-8 shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-border bg-background px-3 text-sm font-medium text-foreground outline-none transition-colors hover:border-primary/60 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none [&>svg]:size-4 [&>svg]:shrink-0 [&>i]:text-base [&>i]:leading-none"
>
<Upload />
Browse
</button>
</div>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Image
A single image with a large preview panel.
import { ImagePreviewFile } from "@/components/ui/file-input-preview"
<ImagePreviewFile />Grid
A square grid of every picked image.
import { GridFile } from "@/components/ui/file-input-preview"
<GridFile />Thumb
A list where each row carries a small thumbnail.
import { ThumbFile } from "@/components/ui/file-input-preview"
<ThumbFile />Media
Cards that tell images, video and audio apart.
import { MediaFile } from "@/components/ui/file-input-preview"
<MediaFile />Card
A card with the file as its cover image.
import { CardFile } from "@/components/ui/file-input-preview"
<CardFile />ai2 Preview file inputs: 5 styled variations on the token system
The ai2 Preview file inputs are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around file pickers that preview the selected file. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion animates previews as they enter and leave the list. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the previews appear instantly and the picker still works.
What is in the ai2 Preview file inputs?
5 exports in one file: Image, Grid, Thumb, Media and Card. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion animates previews as they enter and leave the list.
- Reduced-motion aware: Under prefers-reduced-motion, the previews appear instantly and the picker still works.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Preview file inputs sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.