Progress file inputs
Five file inputs that show what an upload looks like while it runs: a bar, a ring, a percentage row, a multi-file view and a queue. The progress shown is a simulated demo on a fixed deterministic tick, not a real upload, so wire your own transfer state in. 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-progressDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/file-input-progress.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { Check, File as FileIcon, Upload, X } from "lucide-react"
import { cn } from "@/lib/utils"
/* Progress file-input family: 5 decorative pickers that show upload progress.
IMPORTANT: the progress here is a SIMULATED demo progress, not a real upload. No
network request is made; the progress increases by a fixed step at fixed
intervals (a fixed tick), which makes it fully deterministic (no Date.now or
Math.random). Every timer is cleared in the effect cleanup. Each export wraps a
real <input type="file">; color comes ONLY from semantic tokens (transparency via
color-mix). The animations are disabled through useReducedMotion. */
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`
}
/* Ortak dosya-state mantigi: secim ekle/kaldir + disari haber ver. */
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 }
}
/* Simulated progress clock. It increments the counter every TICK_MS and stops itself after MAX_TICKS; cleanup clears the interval in every case. */
const TICK_MS = 120
const STEP = 4
const STAGGER = 6
const MAX_TICKS = 80
function useSimulatedTick(active: boolean, resetKey: string) {
const [tick, setTick] = React.useState(0)
React.useEffect(() => {
if (!active) {
setTick(0)
return
}
setTick(0)
let current = 0
const id = window.setInterval(() => {
current += 1
setTick(current)
if (current >= MAX_TICKS) window.clearInterval(id)
}, TICK_MS)
return () => window.clearInterval(id)
}, [active, resetKey])
return tick
}
/* index'e gore kaydirmali ilerleme: 0..100 arasi, deterministik. */
function progressFor(tick: number, index: number): number {
const raw = (tick - index * STAGGER) * STEP
if (raw <= 0) return 0
return raw >= 100 ? 100 : raw
}
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 iconSize: Record<StyledSize, string> = {
sm: "size-5",
md: "size-6",
lg: "size-8",
xl: "size-9",
}
const barHeight: Record<StyledSize, string> = {
sm: "h-1",
md: "h-1.5",
lg: "h-2",
xl: "h-2.5",
}
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"
/* 1) BarFile: a dropzone plus a horizontal progress bar for every selected file. */
export function BarFile({ className, size = "md", multiple = true, 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(multiple, onFilesChange)
const resetKey = files.map(fileKey).join("|")
const tick = useSimulatedTick(files.length > 0, resetKey)
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose a file to upload
</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 files to upload</span>
<span className="text-xs text-muted-foreground">Progress shown below is a demo</span>
</button>
{files.length > 0 && (
<ul data-slot="styled-file-input-list" className="mt-3 flex flex-col gap-2">
{files.map((file, index) => {
const value = progressFor(tick, index)
const done = value >= 100
return (
<li
key={fileKey(file)}
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm"
>
<div className="flex items-center gap-2">
<FileIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">{file.name}</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{done ? formatSize(file.size) : `${value}%`}
</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(fileKey(file))}
className={clearBtn}
>
<X className="size-4" />
</button>
</div>
<div
role="progressbar"
aria-label={`Uploading ${file.name}`}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
className={cn(
"w-full overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]",
barHeight[size]
)}
>
<motion.div
className={cn("h-full rounded-full", done ? "bg-success" : "bg-primary")}
initial={false}
animate={{ width: `${value}%` }}
transition={reduce ? { duration: 0 } : { duration: 0.12, ease: "linear" }}
/>
</div>
</li>
)
})}
</ul>
)}
</div>
)
}
const ringSize: Record<StyledSize, string> = {
sm: "size-12",
md: "size-16",
lg: "size-20",
xl: "size-24",
}
/* 2) RingFile: dairesel ilerleme halkasi olan tek dosyalik yukleme hedefi. */
export function RingFile({ className, size = "md", accept, onFilesChange }: FileInputProps) {
const reduce = useReducedMotion()
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const { files, add, remove } = useFiles(false, onFilesChange)
const resetKey = files.map(fileKey).join("|")
const tick = useSimulatedTick(files.length > 0, resetKey)
const value = files.length > 0 ? progressFor(tick, 0) : 0
const done = value >= 100
const radius = 46
const circumference = 2 * Math.PI * radius
return (
<div data-slot="styled-file-input" className={cn("flex items-center gap-4", className)}>
<label htmlFor={id} className="sr-only">
Choose a file to upload
</label>
<input
id={id}
ref={inputRef}
type="file"
accept={accept}
className="sr-only"
onChange={(e) => add(e.currentTarget.files)}
/>
<button
type="button"
data-slot="styled-file-input-surface"
aria-label="Choose a file to upload"
onClick={() => inputRef.current?.click()}
className={cn(
"relative flex shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) hover:text-primary focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
ringSize[size]
)}
>
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full -rotate-90">
<circle
cx="50"
cy="50"
r={radius}
fill="none"
strokeWidth="6"
className="stroke-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]"
/>
<motion.circle
cx="50"
cy="50"
r={radius}
fill="none"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circumference}
className={done ? "stroke-success" : "stroke-primary"}
initial={false}
animate={{ strokeDashoffset: circumference * (1 - value / 100) }}
transition={reduce ? { duration: 0 } : { duration: 0.12, ease: "linear" }}
/>
</svg>
{done ? (
<Check className="size-5 text-success" />
) : files.length > 0 ? (
<span className="text-xs font-medium tabular-nums text-foreground">{value}%</span>
) : (
<Upload className="size-5" />
)}
</button>
{files.length > 0 ? (
<div className="flex min-w-0 items-center gap-2 text-sm">
<span className="min-w-0 truncate text-foreground">{files[0].name}</span>
<button
type="button"
aria-label={`Remove ${files[0].name}`}
onClick={() => remove(fileKey(files[0]))}
className={clearBtn}
>
<X className="size-4" />
</button>
</div>
) : (
<span className="truncate text-sm text-muted-foreground">No file chosen</span>
)}
</div>
)
}
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) PercentFile: a row that foregrounds the percentage number and fills behind it. */
export function PercentFile({ className, size = "md", accept, onFilesChange }: FileInputProps) {
const reduce = useReducedMotion()
const id = React.useId()
const inputRef = React.useRef<HTMLInputElement>(null)
const { files, add, remove } = useFiles(false, onFilesChange)
const resetKey = files.map(fileKey).join("|")
const tick = useSimulatedTick(files.length > 0, resetKey)
const value = files.length > 0 ? progressFor(tick, 0) : 0
const done = value >= 100
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose a file to upload
</label>
<input
id={id}
ref={inputRef}
type="file"
accept={accept}
className="sr-only"
onChange={(e) => add(e.currentTarget.files)}
/>
{files.length === 0 ? (
<button
type="button"
data-slot="styled-file-input-surface"
onClick={() => inputRef.current?.click()}
className={cn(
"flex w-full items-center justify-between border border-border bg-surface-2 text-left text-muted-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) hover:border-primary/60 hover:text-foreground 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]
)}
>
<span className="font-medium text-foreground">Choose a file</span>
<Upload />
</button>
) : (
<div
data-slot="styled-file-input-surface"
role="progressbar"
aria-label={`Uploading ${files[0].name}`}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
className={cn(
"relative flex w-full items-center overflow-hidden border border-border bg-surface-2",
rowSize[size]
)}
>
<motion.span
aria-hidden="true"
className={cn(
"absolute inset-y-0 left-0",
done
? "bg-[color-mix(in_oklab,var(--color-success)_16%,transparent)]"
: "bg-[color-mix(in_oklab,var(--color-primary)_16%,transparent)]"
)}
initial={false}
animate={{ width: `${value}%` }}
transition={reduce ? { duration: 0 } : { duration: 0.12, ease: "linear" }}
/>
<span className="relative z-10 flex min-w-0 flex-1 items-center gap-3">
<span
className={cn(
"shrink-0 text-lg font-semibold tabular-nums",
done ? "text-success" : "text-primary"
)}
>
{value}%
</span>
<span className="min-w-0 flex-1 truncate text-foreground">{files[0].name}</span>
</span>
<button
type="button"
aria-label={`Remove ${files[0].name}`}
onClick={() => remove(fileKey(files[0]))}
className={cn(clearBtn, "z-10")}
>
<X className="size-4" />
</button>
</div>
)}
</div>
)
}
/* 4) MultiFile: a dropzone that shows several files uploading at once with staggered progress. */
export function MultiFile({ className, size = "md", multiple = true, 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(multiple, onFilesChange)
const resetKey = files.map(fileKey).join("|")
const tick = useSimulatedTick(files.length > 0, resetKey)
const total = files.length
const overall =
total === 0 ? 0 : Math.round(files.reduce((sum, _f, i) => sum + progressFor(tick, i), 0) / total)
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose files to upload
</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 several files at once</span>
<span className="text-xs text-muted-foreground">Each row keeps its own demo progress</span>
</button>
{total > 0 && (
<div className="mt-3 flex flex-col gap-3 rounded-xl border border-border bg-surface-2 p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{total} file{total > 1 ? "s" : ""}
</span>
<span className="tabular-nums">{overall}% overall</span>
</div>
<ul data-slot="styled-file-input-list" className="flex flex-col gap-2">
{files.map((file, index) => {
const value = progressFor(tick, index)
const done = value >= 100
return (
<li key={fileKey(file)} className="flex items-center gap-2 text-sm">
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-md",
done
? "bg-[color-mix(in_oklab,var(--color-success)_14%,transparent)] text-success"
: "bg-[color-mix(in_oklab,var(--color-primary)_12%,transparent)] text-primary"
)}
>
{done ? <Check className="size-3.5" /> : <FileIcon className="size-3.5" />}
</span>
<span className="min-w-0 flex-1 truncate text-foreground">{file.name}</span>
<span
role="progressbar"
aria-label={`Uploading ${file.name}`}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
className="h-1 w-20 shrink-0 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]"
>
<motion.span
className={cn("block h-full rounded-full", done ? "bg-success" : "bg-primary")}
initial={false}
animate={{ width: `${value}%` }}
transition={reduce ? { duration: 0 } : { duration: 0.12, ease: "linear" }}
/>
</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(fileKey(file))}
className={clearBtn}
>
<X className="size-4" />
</button>
</li>
)
})}
</ul>
</div>
)}
</div>
)
}
/* 5) QueueFile: an ordered queue view. Only the file whose turn it is shows as
"uploading", the ones before it as "done" and the ones after it as "queued". */
export function QueueFile({ 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 resetKey = files.map(fileKey).join("|")
const tick = useSimulatedTick(files.length > 0, resetKey)
return (
<div data-slot="styled-file-input" className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
Choose files to queue
</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 />
Add to queue
</button>
{files.length > 0 && (
<ol data-slot="styled-file-input-list" className="mt-3 flex flex-col gap-px overflow-hidden rounded-lg border border-border">
{files.map((file, index) => {
const value = progressFor(tick, index)
const state = value >= 100 ? "done" : value > 0 ? "uploading" : "queued"
return (
<li
key={fileKey(file)}
data-state={state}
className="flex items-center gap-3 bg-surface-2 px-3 py-2 text-sm"
>
<span className="w-5 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate text-foreground">{file.name}</span>
<motion.span
initial={false}
animate={reduce ? undefined : { opacity: 1 }}
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums",
state === "done" &&
"bg-[color-mix(in_oklab,var(--color-success)_14%,transparent)] text-success",
state === "uploading" &&
"bg-[color-mix(in_oklab,var(--color-primary)_14%,transparent)] text-primary",
state === "queued" && "bg-surface-3 text-muted-foreground"
)}
>
{state === "done" ? "Done" : state === "uploading" ? `${value}%` : "Queued"}
</motion.span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(fileKey(file))}
className={clearBtn}
>
<X className="size-4" />
</button>
</li>
)
})}
</ol>
)}
</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.
Bar
A dropzone with a horizontal progress bar per file.
import { BarFile } from "@/components/ui/file-input-progress"
<BarFile />Ring
A circular progress ring around a single upload target.
import { RingFile } from "@/components/ui/file-input-progress"
<RingFile />Percent
A row that fills behind a large percentage readout.
import { PercentFile } from "@/components/ui/file-input-progress"
<PercentFile />Multi
Several files advancing at once with an overall total.
import { MultiFile } from "@/components/ui/file-input-progress"
<MultiFile />Queue
A numbered queue marking each file done, uploading or queued.
import { QueueFile } from "@/components/ui/file-input-progress"
<QueueFile />ai2 Progress file inputs: 5 styled variations on the token system
The ai2 Progress file inputs are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around file pickers that show upload progress affordances. 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 the bar, the ring and the fill on a fixed simulated tick. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the progress snaps to its value with no animation and the picker still works.
What is in the ai2 Progress file inputs?
5 exports in one file: Bar, Ring, Percent, Multi and Queue. 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 the bar, the ring and the fill on a fixed simulated tick.
- Reduced-motion aware: Under prefers-reduced-motion, the progress snaps to its value with no animation 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 Progress 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.