Tone file inputs
Five file inputs that keep one dropzone anatomy and change only meaning: info, success, warning, danger and muted. The tone drives the border, the surface mix, the icon and the hint copy, and lands on the root as a data-tone attribute. Each is sized, token-driven, keyboard accessible and supports drag and drop.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/file-input-toneDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/file-input-tone.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
AlertTriangle,
CheckCircle2,
File as FileIcon,
Info,
ShieldAlert,
Upload,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
/* Tone file-input family: decorative pickers carrying the same dropzone anatomy in
5 semantic tones. The tone changes only the color/icon/hint text; the anatomy and
the behavior are identical. Each export wraps a real <input type="file"> and
supports real drag and drop (preventDefault). Color comes ONLY from semantic
tokens, with transparency via color-mix. The invalid appearance uses the ai2
danger tokens (not destructive). The animations are disabled through
useReducedMotion. Deterministic. */
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 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 }
}
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 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"
type Tone = "info" | "success" | "warning" | "danger" | "muted"
/* Surface, icon and list class per tone. Transparency only through color-mix. */
const toneSurface: Record<Tone, string> = {
info: "border-info bg-[color-mix(in_oklab,var(--color-info)_6%,transparent)] hover:bg-[color-mix(in_oklab,var(--color-info)_10%,transparent)] data-[drag-active]:bg-[color-mix(in_oklab,var(--color-info)_16%,transparent)]",
success:
"border-success bg-[color-mix(in_oklab,var(--color-success)_6%,transparent)] hover:bg-[color-mix(in_oklab,var(--color-success)_10%,transparent)] data-[drag-active]:bg-[color-mix(in_oklab,var(--color-success)_16%,transparent)]",
warning:
"border-warning bg-[color-mix(in_oklab,var(--color-warning)_6%,transparent)] hover:bg-[color-mix(in_oklab,var(--color-warning)_10%,transparent)] data-[drag-active]:bg-[color-mix(in_oklab,var(--color-warning)_16%,transparent)]",
danger:
"border-danger bg-[color-mix(in_oklab,var(--color-danger)_6%,transparent)] hover:bg-[color-mix(in_oklab,var(--color-danger)_10%,transparent)] data-[drag-active]:bg-[color-mix(in_oklab,var(--color-danger)_16%,transparent)]",
muted:
"border-border bg-[color-mix(in_oklab,var(--color-foreground)_4%,transparent)] hover:bg-[color-mix(in_oklab,var(--color-foreground)_7%,transparent)] data-[drag-active]:bg-[color-mix(in_oklab,var(--color-foreground)_10%,transparent)]",
}
const toneIcon: Record<Tone, string> = {
info: "text-info",
success: "text-success",
warning: "text-warning",
danger: "text-danger",
muted: "text-muted-foreground",
}
const toneChip: Record<Tone, string> = {
info: "bg-[color-mix(in_oklab,var(--color-info)_14%,transparent)] text-info",
success: "bg-[color-mix(in_oklab,var(--color-success)_14%,transparent)] text-success",
warning: "bg-[color-mix(in_oklab,var(--color-warning)_14%,transparent)] text-warning",
danger: "bg-[color-mix(in_oklab,var(--color-danger)_14%,transparent)] text-danger",
muted: "bg-surface-3 text-muted-foreground",
}
/* Shared shell: a hidden real input plus a focusable dropzone button plus the file list. */
function ToneShell({
tone,
icon,
headline,
hint,
className,
size = "md",
multiple = true,
accept,
onFilesChange,
}: FileInputProps & {
tone: Tone
icon: React.ReactNode
headline: string
hint: string
}) {
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)
return (
<div data-slot="styled-file-input" data-tone={tone} className={cn("w-full", className)}>
<label htmlFor={id} className="sr-only">
{headline}
</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-tone={tone}
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(
"flex w-full flex-col items-center justify-center border border-dashed text-center text-muted-foreground outline-none transition-colors duration-(--motion-base) ease-(--motion-ease) focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[drag-active]:text-foreground motion-reduce:transition-none [&>svg]:shrink-0 [&>i]:leading-none",
toneSurface[tone],
dropSurface[size]
)}
>
<span className={cn("flex items-center justify-center", iconSize[size], toneIcon[tone])}>
{icon}
</span>
<span className="font-medium text-foreground">{headline}</span>
<span className="text-xs text-muted-foreground">{hint}</span>
</button>
<AnimatePresence initial={false}>
{files.length > 0 && (
<motion.ul
data-slot="styled-file-input-list"
className="mt-3 flex flex-col gap-2"
initial={reduce ? undefined : { opacity: 0, height: 0 }}
animate={reduce ? undefined : { opacity: 1, height: "auto" }}
exit={reduce ? undefined : { opacity: 0, height: 0 }}
>
{files.map((file) => (
<li
key={fileKey(file)}
className="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm"
>
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
toneChip[tone]
)}
>
<FileIcon className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-foreground">{file.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatSize(file.size)}</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => remove(fileKey(file))}
className={clearBtn}
>
<X className="size-4" />
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
)
}
/* 1) InfoFile: notr bilgilendirici ton, info token'lari. */
export function InfoFile(props: FileInputProps) {
return (
<ToneShell
{...props}
tone="info"
icon={<Info className="size-full" />}
headline="Drop files to attach them"
hint="Anything you attach stays private to this record"
/>
)
}
/* 2) SuccessFile: onaylanmis/kabul edilmis ton, success token'lari. */
export function SuccessFile(props: FileInputProps) {
return (
<ToneShell
{...props}
tone="success"
icon={<CheckCircle2 className="size-full" />}
headline="Drop verified documents here"
hint="These file types are accepted"
/>
)
}
/* 3) WarningFile: dikkat isteyen ton, warning token'lari. */
export function WarningFile(props: FileInputProps) {
return (
<ToneShell
{...props}
tone="warning"
icon={<AlertTriangle className="size-full" />}
headline="Drop files with care"
hint="Large files may take a while to process"
/>
)
}
/* 4) DangerFile: the risky or rejected tone, ai2 danger tokens (not destructive). */
export function DangerFile(props: FileInputProps) {
return (
<ToneShell
{...props}
tone="danger"
icon={<ShieldAlert className="size-full" />}
headline="Restricted upload area"
hint="Unsupported files will be rejected"
/>
)
}
/* 5) MutedFile: sessiz/ikincil ton, notr foreground karisimlari. */
export function MutedFile(props: FileInputProps) {
return (
<ToneShell
{...props}
tone="muted"
icon={<Upload className="size-full" />}
headline="Optional attachments"
hint="You can skip this step"
/>
)
}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.
Info
A neutral informative dropzone on the info tokens.
import { InfoFile } from "@/components/ui/file-input-tone"
<InfoFile />Success
An accepted or verified dropzone on the success tokens.
import { SuccessFile } from "@/components/ui/file-input-tone"
<SuccessFile />Warning
A dropzone that asks for care on the warning tokens.
import { WarningFile } from "@/components/ui/file-input-tone"
<WarningFile />Danger
A restricted dropzone on the ai2 danger tokens.
import { DangerFile } from "@/components/ui/file-input-tone"
<DangerFile />Muted
A quiet optional dropzone on neutral mixes.
import { MutedFile } from "@/components/ui/file-input-tone"
<MutedFile />ai2 Tone file inputs: 5 styled variations on the token system
The ai2 Tone file inputs are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around dropzones carrying a semantic tone. 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: the drag-active state runs on token CSS transitions and framer-motion reveals the file 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 reveal is skipped and the picker still works.
What is in the ai2 Tone file inputs?
5 exports in one file: Info, Success, Warning, Danger and Muted. 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: the drag-active state runs on token CSS transitions and framer-motion reveals the file list.
- Reduced-motion aware: Under prefers-reduced-motion, the reveal is skipped 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 Tone 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.