# File input

> A file input on Vercel's UI SDK with a drop zone, paste, type and size checks, rejected files with reasons, image previews and form support.

Canonical: https://hextaui.com/docs/ui-sdk-file-input
Last updated: 2026-11-14

> **Beta.** Built on Vercel’s UI SDK 0.1.0-beta.2 (`@ui-sdk/react`), a public beta. Its API may change.

```tsx title="components/examples/ui-sdk-file-input/demo.tsx"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputDemo() {
  return (
    <FileInput
      multiple
      accept={["image/*", ".pdf"]}
      maxFiles={5}
      maxSize={5_000_000}
      className="max-w-md"
    >
      <FileInputDropzone>
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Drop files here</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Images or PDFs, up to 5 files of 5 MB each. You can paste them too.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Browse files</FileInputTrigger>
      </FileInputDropzone>
      <FileInputRejections />
      <FileInputList aria-label="Files">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputDemo }
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/ui-sdk-file-input.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @ui-sdk/react@0.1.0-beta.2 @ui-sdk/core@0.1.0-beta.2 @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/ui-sdk-file-input.tsx"
"use client"

import * as React from "react"
import {
  IconAlertCircle,
  IconCode,
  IconFile,
  IconFileText,
  IconFileZip,
  IconMusic,
  IconPhoto,
  IconTable,
  IconUpload,
  IconVideo,
  IconX,
} from "@tabler/icons-react"
import * as FileInputPrimitive from "@ui-sdk/react/file-input"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { buttonVariants } from "@/components/ui/button"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

type FileRejection = FileInputPrimitive.FileRejection
type FileRejectionReason = FileRejection["reasons"][number]

type FileInputRules = {
  accept?: string | readonly string[]
  maxFiles?: number
  maxSize?: number
  minSize?: number
  multiple?: boolean
}

type FileInputContextValue = {
  invalid: boolean
  rejected: FileRejection[]
  rules: FileInputRules
  clearRejected: () => void
}

const FileInputContext = React.createContext<FileInputContextValue>({
  invalid: false,
  rejected: [],
  rules: {},
  clearRejected: () => {},
})

const FileInputItemContext = React.createContext<File | null>(null)

function useFileInputRejections() {
  const { rejected, clearRejected } = React.useContext(FileInputContext)
  return { rejected, clear: clearRejected }
}

function useFileInputItemFile(part: string) {
  const file = React.useContext(FileInputItemContext)
  if (!file) {
    throw new Error(`${part} must be used inside <FileInputItem>.`)
  }
  return file
}

type FileInputProps = React.ComponentProps<
  typeof FileInputPrimitive.FileInput
> & {
  invalid?: boolean
}

function FileInput({
  className,
  invalid = false,
  accept,
  maxFiles,
  maxSize,
  minSize,
  multiple,
  directory,
  onReject,
  onValueChange,
  ...props
}: FileInputProps) {
  const [rejected, setRejected] = React.useState<FileRejection[]>([])
  const clearRejected = React.useCallback(() => setRejected([]), [])
  const context = React.useMemo(
    () => ({
      invalid,
      rejected,
      clearRejected,
      rules: {
        accept,
        maxFiles,
        maxSize,
        minSize,
        multiple: multiple || directory,
      },
    }),
    [
      invalid,
      rejected,
      clearRejected,
      accept,
      maxFiles,
      maxSize,
      minSize,
      multiple,
      directory,
    ]
  )

  return (
    <FileInputContext.Provider value={context}>
      <FileInputPrimitive.FileInput
        data-invalid={invalid ? "" : undefined}
        accept={accept}
        maxFiles={maxFiles}
        maxSize={maxSize}
        minSize={minSize}
        multiple={multiple}
        directory={directory}
        onValueChange={(value, details) => {
          if (details.reason === "reset" || details.added.length > 0) {
            setRejected(details.rejected)
          }
          onValueChange?.(value, details)
        }}
        onReject={(files, details) => {
          setRejected(files)
          onReject?.(files, details)
        }}
        className={mergeClassName(
          "group/file-input flex w-full min-w-0 flex-col gap-3",
          className
        )}
        {...props}
      />
    </FileInputContext.Provider>
  )
}

const fileInputDropzoneVariants = cva(
  "group/file-input-dropzone text-sm transition-[background-color,border-color,opacity,visibility] duration-150 ease-out-quint motion-reduce:transition-none",
  {
    variants: {
      variant: {
        default:
          "relative flex min-h-40 w-full min-w-0 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-input px-6 py-8 text-center group-data-invalid/file-input:border-destructive/70 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-dragging:border-ring data-dragging:bg-muted/50 data-drag-reject:border-destructive data-drag-reject:bg-destructive/5 dark:bg-input/10 dark:data-dragging:bg-input/30 dark:data-drag-reject:bg-destructive/10 forced-colors:data-dragging:border-[Highlight]",
        overlay:
          "pointer-events-none invisible fixed inset-0 z-50 m-0 flex flex-col items-center justify-center gap-1 bg-background/85 p-6 text-center opacity-0 backdrop-blur-sm before:pointer-events-none before:absolute before:inset-3 before:rounded-2xl before:border-2 before:border-dashed before:border-ring/60 data-dragging:visible data-dragging:opacity-100 data-drag-reject:before:border-destructive/70 forced-colors:bg-[Canvas]",
        composer:
          "relative flex w-full min-w-0 flex-col gap-2 rounded-2xl bg-card p-2 inset-ring-(length:--hairline) inset-ring-border transition-[background-color,box-shadow] group-data-invalid/file-input:inset-ring-destructive/70 has-[textarea:focus-visible]:ring-3 has-[textarea:focus-visible]:ring-focus-ring has-[textarea:focus-visible]:inset-ring-ring data-disabled:opacity-50 data-dragging:bg-muted/40 data-dragging:inset-ring-ring data-drag-reject:bg-destructive/5 data-drag-reject:inset-ring-destructive dark:bg-input/20 dark:data-dragging:bg-input/40 forced-colors:border",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function FileInputDropzone({
  className,
  variant = "default",
  ...props
}: React.ComponentProps<typeof FileInputPrimitive.FileInputDropzone> &
  VariantProps<typeof fileInputDropzoneVariants>) {
  return (
    <FileInputPrimitive.FileInputDropzone
      data-variant={variant}
      className={mergeClassName(
        fileInputDropzoneVariants({ variant }),
        className
      )}
      {...props}
    />
  )
}

function FileInputDropzoneIcon({
  className,
  children,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      aria-hidden="true"
      data-slot="file-input-dropzone-icon"
      className={cn(
        "relative z-10 mb-2 flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground transition-[translate,color,background-color] duration-200 ease-out-quint group-data-drag-accept/file-input-dropzone:bg-foreground group-data-drag-accept/file-input-dropzone:text-background group-data-drag-reject/file-input-dropzone:bg-destructive/10 group-data-drag-reject/file-input-dropzone:text-destructive motion-safe:group-data-drag-accept/file-input-dropzone:-translate-y-1 motion-reduce:transition-colors forced-colors:border [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
        className
      )}
      {...props}
    >
      {children ?? <IconUpload />}
    </div>
  )
}

function FileInputDropzoneTitle({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="file-input-dropzone-title"
      className={cn(
        "relative z-10 m-0 font-medium text-foreground text-balance",
        className
      )}
      {...props}
    />
  )
}

function FileInputDropzoneDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="file-input-dropzone-description"
      className={cn(
        "relative z-10 m-0 text-xs text-muted-foreground text-pretty",
        className
      )}
      {...props}
    />
  )
}

function FileInputTrigger({
  className,
  variant = "outline",
  size = "sm",
  shape,
  ...props
}: React.ComponentProps<typeof FileInputPrimitive.FileInputTrigger> &
  VariantProps<typeof buttonVariants>) {
  const { invalid } = React.useContext(FileInputContext)
  return (
    <FileInputPrimitive.FileInputTrigger
      aria-invalid={invalid || undefined}
      className={mergeClassName(
        cn(
          buttonVariants({ variant, size, shape }),
          "relative z-10 data-invalid:ring-3 data-invalid:ring-destructive/20 data-invalid:inset-ring-destructive"
        ),
        className
      )}
      {...props}
    />
  )
}

type FileInputListLayout = "list" | "grid" | "chips"

const FileInputListContext = React.createContext<FileInputListLayout>("list")

const fileInputListVariants = cva("w-full min-w-0 empty:hidden", {
  variants: {
    layout: {
      list: "flex flex-col gap-1.5",
      grid: "grid grid-cols-[repeat(auto-fill,minmax(--spacing(24),1fr))] gap-2",
      chips: "flex flex-row flex-wrap gap-1.5",
    },
  },
  defaultVariants: {
    layout: "list",
  },
})

function FileInputList({
  className,
  layout = "list",
  ...props
}: React.ComponentProps<typeof FileInputPrimitive.FileInputList> &
  VariantProps<typeof fileInputListVariants>) {
  const resolved = layout ?? "list"
  return (
    <FileInputListContext.Provider value={resolved}>
      <FileInputPrimitive.FileInputList
        data-layout={resolved}
        className={mergeClassName(
          fileInputListVariants({ layout: resolved }),
          className
        )}
        {...props}
      />
    </FileInputListContext.Provider>
  )
}

const fileInputItemVariants = cva(
  "group/file-input-item relative flex min-w-0 bg-card text-card-foreground inset-ring-(length:--hairline) inset-ring-border transition-[background-color,box-shadow] duration-150 ease-out-quint outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring data-disabled:opacity-50 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:animation-duration-200 motion-safe:ease-out-quint dark:bg-input/20 forced-colors:border",
  {
    variants: {
      layout: {
        list: "items-center gap-3 rounded-lg p-2 pe-1.5 text-sm motion-safe:slide-in-from-top-1",
        grid: "aspect-square overflow-hidden rounded-lg motion-safe:zoom-in-95",
        chips:
          "max-w-60 items-center gap-2 rounded-lg p-1 pe-1 text-xs motion-safe:zoom-in-95",
      },
    },
    defaultVariants: {
      layout: "list",
    },
  }
)

type FileInputItemProps = React.ComponentProps<
  typeof FileInputPrimitive.FileInputItem
>

function FileInputItemDefault({ layout }: { layout: FileInputListLayout }) {
  if (layout === "grid") {
    return (
      <>
        <FileInputItemPreview />
        <FileInputItemRemove />
      </>
    )
  }
  return (
    <>
      <FileInputItemPreview />
      <FileInputItemContent>
        <FileInputItemName />
        <FileInputItemSize />
      </FileInputItemContent>
      <FileInputItemRemove />
    </>
  )
}

function FileInputItem({ className, children, ...props }: FileInputItemProps) {
  const layout = React.useContext(FileInputListContext)
  return (
    <FileInputPrimitive.FileInputItem
      data-layout={layout}
      className={mergeClassName(fileInputItemVariants({ layout }), className)}
      {...props}
    >
      {(state) => (
        <FileInputItemContext.Provider value={state.file}>
          {typeof children === "function"
            ? children(state)
            : (children ?? <FileInputItemDefault layout={layout} />)}
        </FileInputItemContext.Provider>
      )}
    </FileInputPrimitive.FileInputItem>
  )
}

function fileIcon(file: File) {
  const type = file.type
  const name = file.name.toLowerCase()
  if (type.startsWith("image/")) return IconPhoto
  if (type.startsWith("video/")) return IconVideo
  if (type.startsWith("audio/")) return IconMusic
  if (/\.(zip|gz|tgz|rar|7z|tar)$/.test(name) || type.includes("zip")) {
    return IconFileZip
  }
  if (/\.(csv|tsv|xlsx?|numbers)$/.test(name)) return IconTable
  if (/\.(jsx?|tsx?|json|css|html?|py|rb|go|rs|sh|ya?ml|md)$/.test(name)) {
    return IconCode
  }
  if (type === "application/pdf" || type.startsWith("text/")) {
    return IconFileText
  }
  return IconFile
}

function useObjectUrl(file: File, enabled: boolean) {
  const [url, setUrl] = React.useState<{ file: File; url: string } | null>(
    null
  )
  React.useEffect(() => {
    if (!enabled) return
    const next = URL.createObjectURL(file)
    setUrl({ file, url: next })
    return () => URL.revokeObjectURL(next)
  }, [file, enabled])
  return url?.file === file ? url.url : undefined
}

function FileInputItemPreview({
  className,
  ...props
}: React.ComponentProps<"span">) {
  const file = useFileInputItemFile("FileInputItemPreview")
  const isImage =
    file.type.startsWith("image/") && file.type !== "image/svg+xml"
  const url = useObjectUrl(file, isImage)
  const [broken, setBroken] = React.useState<string | null>(null)
  const Icon = fileIcon(file)
  const shown = url && broken !== url ? url : undefined

  return (
    <span
      aria-hidden="true"
      data-slot="file-input-item-preview"
      data-kind={shown ? "image" : "icon"}
      className={cn(
        "relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:inset-ring-(length:--hairline) after:inset-ring-foreground/10 group-data-[layout=chips]/file-input-item:size-8 group-data-[layout=chips]/file-input-item:rounded-md group-data-[layout=grid]/file-input-item:size-full group-data-[layout=grid]/file-input-item:rounded-none group-data-[layout=grid]/file-input-item:after:hidden [&_svg]:size-4.5 [&_svg]:shrink-0 group-data-[layout=grid]/file-input-item:[&_svg]:size-6",
        className
      )}
      {...props}
    >
      {shown ? (
        <img
          src={shown}
          alt=""
          draggable={false}
          onError={() => setBroken(shown)}
          className="size-full max-w-none object-cover"
        />
      ) : (
        <Icon />
      )}
    </span>
  )
}

function FileInputItemContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="file-input-item-content"
      className={cn(
        "flex min-w-0 flex-1 flex-col gap-0.5 leading-tight",
        className
      )}
      {...props}
    />
  )
}

function splitFileName(name: string) {
  if (/[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]/.test(name)) return null
  const dot = name.lastIndexOf(".")
  if (dot <= 0 || dot === name.length - 1 || name.length - dot > 8) {
    return null
  }
  return { base: name.slice(0, dot), extension: name.slice(dot) }
}

function FileInputItemName({
  className,
  children,
  ...props
}: React.ComponentProps<"span">) {
  const file = useFileInputItemFile("FileInputItemName")
  const name = children ?? FileInputPrimitive.relativePath(file)
  const parts = typeof name === "string" ? splitFileName(name) : null

  return (
    <span
      data-slot="file-input-item-name"
      title={typeof name === "string" ? name : undefined}
      dir="auto"
      className={cn(
        "max-w-full min-w-0 font-medium group-data-[layout=chips]/file-input-item:text-xs",
        parts ? "flex" : "block truncate",
        className
      )}
      {...props}
    >
      {parts ? (
        <>
          <span className="min-w-0 truncate">{parts.base}</span>
          <span className="shrink-0">{parts.extension}</span>
        </>
      ) : (
        name
      )}
    </span>
  )
}

function FileInputItemSize({
  className,
  locale,
  ...props
}: React.ComponentProps<"span"> & { locale?: string }) {
  const file = useFileInputItemFile("FileInputItemSize")
  return (
    <span
      data-slot="file-input-item-size"
      className={cn("text-xs text-muted-foreground tabular-nums", className)}
      {...props}
    >
      {FileInputPrimitive.formatFileSize(file.size, locale)}
    </span>
  )
}

function FileInputItemRemove({
  className,
  children,
  ...props
}: React.ComponentProps<typeof FileInputPrimitive.FileInputItemRemove>) {
  return (
    <FileInputPrimitive.FileInputItemRemove
      className={mergeClassName(
        "relative inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-[color,background-color] duration-150 ease-out-quint outline-none after:absolute after:-inset-1 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50 pointer-coarse:after:-inset-2 group-data-[layout=chips]/file-input-item:size-6 group-data-[layout=grid]/file-input-item:absolute group-data-[layout=grid]/file-input-item:end-1.5 group-data-[layout=grid]/file-input-item:top-1.5 group-data-[layout=grid]/file-input-item:size-6 group-data-[layout=grid]/file-input-item:rounded-full group-data-[layout=grid]/file-input-item:bg-background/80 group-data-[layout=grid]/file-input-item:text-foreground group-data-[layout=grid]/file-input-item:backdrop-blur-sm group-data-[layout=grid]/file-input-item:hover:bg-background dark:hover:bg-muted/60 forced-colors:group-data-[layout=grid]/file-input-item:bg-[Canvas] [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 group-data-[layout=chips]/file-input-item:[&_svg]:size-3.5 group-data-[layout=grid]/file-input-item:[&_svg]:size-3.5",
        className
      )}
      {...props}
    >
      {children ?? <IconX aria-hidden="true" />}
    </FileInputPrimitive.FileInputItemRemove>
  )
}

function plural(count: number, one: string, other: string) {
  return count === 1 ? one : other
}

function defaultReasonLabel(reason: FileRejectionReason, rules: FileInputRules) {
  switch (reason) {
    case "file-type":
      return "This file type isn’t accepted"
    case "file-too-large":
      return rules.maxSize === undefined
        ? "Too large"
        : `Larger than ${FileInputPrimitive.formatFileSize(rules.maxSize)}`
    case "file-too-small":
      return rules.minSize === undefined
        ? "Too small"
        : `Smaller than ${FileInputPrimitive.formatFileSize(rules.minSize)}`
    case "too-many-files":
      return rules.multiple && rules.maxFiles !== undefined
        ? `Over the limit of ${rules.maxFiles} ${plural(rules.maxFiles, "file", "files")}`
        : "Only one file at a time"
    default:
      return reason
  }
}

type FileInputRejectionsProps = Omit<
  React.ComponentProps<"ul">,
  "children"
> & {
  getReasonLabel?: (
    reason: FileRejectionReason,
    rules: FileInputRules,
    rejection: FileRejection
  ) => string
}

function FileInputRejections({
  className,
  getReasonLabel = defaultReasonLabel,
  ...props
}: FileInputRejectionsProps) {
  const { rejected, rules } = React.useContext(FileInputContext)
  if (rejected.length === 0) return null

  return (
    <ul
      data-slot="file-input-rejections"
      className={cn(
        "m-0 flex w-full min-w-0 list-none flex-col gap-1 p-0 text-sm",
        className
      )}
      {...props}
    >
      {rejected.map((rejection, index) => (
        <li
          key={`${rejection.file.name}-${index}`}
          data-slot="file-input-rejection"
          className="flex min-w-0 items-start gap-2 text-destructive motion-safe:animate-in motion-safe:fade-in-0 motion-safe:animation-duration-200"
        >
          <IconAlertCircle
            aria-hidden="true"
            className="mt-0.5 size-4 shrink-0"
          />
          <span className="min-w-0">
            <span
              dir="auto"
              className="font-medium [overflow-wrap:anywhere]"
            >
              {rejection.file.name}
            </span>
            <span className="text-destructive/80">
              {": "}
              {rejection.reasons
                .map((reason) => getReasonLabel(reason, rules, rejection))
                .join(", ")}
            </span>
          </span>
        </li>
      ))}
    </ul>
  )
}

const createFileInputController = FileInputPrimitive.createFileInputController
const formatFileSize = FileInputPrimitive.formatFileSize
const relativePath = FileInputPrimitive.relativePath

export {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputDropzoneDescription,
  FileInputTrigger,
  FileInputList,
  FileInputItem,
  FileInputItemPreview,
  FileInputItemContent,
  FileInputItemName,
  FileInputItemSize,
  FileInputItemRemove,
  FileInputRejections,
  createFileInputController,
  fileInputDropzoneVariants,
  fileInputItemVariants,
  fileInputListVariants,
  formatFileSize,
  relativePath,
  useFileInputRejections,
}
export type { FileInputProps, FileInputRules, FileRejection }
export type {
  FileInputController,
  FileInputValueChangeDetails,
} from "@ui-sdk/react/file-input"
```

```tsx title="components/ui/button.tsx"
"use client"

import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import {
  useButtonFeedback,
  type ButtonFeedbackOptions,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

const buttonVariants = cva(
  "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
        "ghost-destructive":
          "text-destructive hover:bg-destructive/10 hover:text-destructive aria-expanded:bg-destructive/10 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:hover:bg-destructive/20 forced-colors:border-0",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
        link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
        success:
          "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
        error:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-2.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:inset-x-0 pointer-coarse:in-data-[slot=button-group]:after:hidden has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        icon: "size-9 shrink-0",
        "icon-xs":
          "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
        "icon-lg": "size-10 shrink-0",
        "icon-xl": "size-12 shrink-0 [&_svg:not([class*='size-'])]:size-5",
      },
      shape: {
        default: "",
        pill: "rounded-full in-data-[slot=button-group]:rounded-full",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
      shape: "default",
    },
  }
)

type ButtonVariant = Exclude<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  "success" | "error"
>

type ButtonClickEvent = Parameters<
  NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  Omit<VariantProps<typeof buttonVariants>, "variant"> &
  ButtonFeedbackOptions & {
    variant?: ButtonVariant | null
    onClick?: (event: ButtonClickEvent) => unknown
    feedback?: boolean
    loading?: boolean
    status?: ButtonStatus
    loadingLabel?: React.ReactNode
    successLabel?: React.ReactNode
    errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
  }

const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
  success: "text-success hover:text-success",
  error:
    "text-destructive hover:text-destructive motion-safe:animate-button-shake",
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useStatusTransition(status: ButtonStatus) {
  const [shown, setShown] = React.useState(status)
  const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
  const [transitionKey, setTransitionKey] = React.useState(0)

  if (status !== shown) {
    setLeaving(shown)
    setShown(status)
    setTransitionKey((key) => key + 1)
  }

  React.useEffect(() => {
    if (leaving === null) {
      return
    }
    const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
    return () => clearTimeout(timer)
  }, [leaving, transitionKey])

  return { leaving, transitionKey }
}

function useLayerWidth(status: ButtonStatus, active: boolean) {
  const contentRef = React.useRef<HTMLSpanElement>(null)
  const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
  const observerRef = React.useRef<ResizeObserver | null>(null)
  const statusRef = React.useRef(status)

  const applyWidth = React.useCallback(() => {
    const content = contentRef.current
    const layer = layersRef.current.get(statusRef.current)
    if (!content || !layer) {
      return
    }
    const natural = parseFloat(getComputedStyle(layer).width)
    if (!Number.isFinite(natural)) {
      return
    }
    content.style.width = `${natural}px`
    if (!content.hasAttribute("data-measured")) {
      requestAnimationFrame(() => content.setAttribute("data-measured", ""))
    }
  }, [])

  React.useLayoutEffect(() => {
    statusRef.current = status
    if (active) {
      applyWidth()
    }
  }, [active, applyWidth, status])

  React.useLayoutEffect(() => {
    if (!active || typeof ResizeObserver === "undefined") {
      return
    }
    const observer = new ResizeObserver(applyWidth)
    observerRef.current = observer
    layersRef.current.forEach((layer) => observer.observe(layer))
    document.fonts?.addEventListener?.("loadingdone", applyWidth)
    return () => {
      observer.disconnect()
      observerRef.current = null
      document.fonts?.removeEventListener?.("loadingdone", applyWidth)
    }
  }, [active, applyWidth])

  const layerRef = React.useCallback((node: HTMLElement | null) => {
    if (!node) {
      return
    }
    const layer = node.dataset.layer as ButtonStatus
    layersRef.current.set(layer, node)
    observerRef.current?.observe(node)
    return () => {
      observerRef.current?.unobserve(node)
      if (layersRef.current.get(layer) === node) {
        layersRef.current.delete(layer)
      }
    }
  }, [])

  return { contentRef, layerRef }
}

function composeHandlers<Event>(
  first: ((event: Event) => void) | undefined,
  second: (event: Event) => void
) {
  return (event: Event) => {
    first?.(event)
    second(event)
  }
}

function Button({
  className,
  variant = "default",
  size = "default",
  shape = "default",
  children,
  onClick,
  disabled,
  focusableWhenDisabled,
  feedback,
  loading,
  status: statusProp,
  onStatusChange,
  onError,
  resetAfter,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
  const status = statusProp ?? (loading ? "loading" : internal.status)
  const requested =
    Boolean(feedback) || loading !== undefined || statusProp !== undefined
  const [enabled, setEnabled] = React.useState(requested)
  if (requested && !enabled) {
    setEnabled(true)
  }

  const { leaving, transitionKey } = useStatusTransition(status)
  const iconOnly = size?.startsWith("icon") ?? false
  const widthStatus =
    status === "loading" && loadingLabel === undefined ? "idle" : status
  const { contentRef, layerRef } = useLayerWidth(
    widthStatus,
    enabled && !iconOnly
  )
  const isLoading = status === "loading"
  const quiet = quietVariants.includes(variant ?? "default")
  const resolvedVariant =
    !quiet && status === "success"
      ? "success"
      : !quiet && status === "error"
        ? "error"
        : variant
  const resolvedErrorLabel =
    typeof errorLabel !== "function"
      ? errorLabel
      : internal.error !== undefined
        ? errorLabel(internal.error)
        : "Failed"

  const handleClick = (event: ButtonClickEvent) => {
    if (feedback && internal.isPending()) {
      return
    }
    const result = onClick?.(event)
    if (feedback && isThenable(result)) {
      internal.track(result)
    }
  }

  const layerContent = (layer: ButtonStatus, animated: boolean) => {
    if (layer === "idle") {
      return children
    }
    if (layer === "loading") {
      return (
        <>
          <Spinner aria-hidden size={null} animated={animated} />
          {iconOnly ? null : loadingLabel}
        </>
      )
    }
    if (layer === "success") {
      return (
        <>
          <IconCircleCheck
            className={cn(
              animated &&
                "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
            )}
          />
          {iconOnly ? null : successLabel}
        </>
      )
    }
    return (
      <>
        <IconAlertCircle />
        {iconOnly ? null : resolvedErrorLabel}
      </>
    )
  }

  const button = (
    <ButtonPrimitive
      data-slot="button"
      data-shape={shape ?? "default"}
      data-status={enabled ? status : undefined}
      aria-busy={isLoading || undefined}
      disabled={disabled || isLoading}
      focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
      className={cn(
        buttonVariants({ variant: resolvedVariant, size, shape }),
        quiet && quietStatusClasses[status],
        className
      )}
      onClick={handleClick}
      onPointerEnter={composeHandlers(
        onPointerEnter,
        internal.buttonProps.onPointerEnter
      )}
      onPointerLeave={composeHandlers(
        onPointerLeave,
        internal.buttonProps.onPointerLeave
      )}
      onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
      onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
      {...props}
    >
      {enabled ? (
        <span
          ref={contentRef}
          data-slot="button-content"
          className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
        >
          {statuses.map((layer) => {
            const active = layer === status
            const exiting = layer === leaving
            const namesButton =
              layer === "idle" && isLoading && loadingLabel === undefined

            return (
              <span
                key={active ? `${layer}-${transitionKey}` : layer}
                ref={layerRef}
                data-slot="button-layer"
                data-layer={layer}
                aria-hidden={active || namesButton ? undefined : true}
                className={cn(
                  "inline-flex items-center justify-center gap-[inherit] backface-hidden",
                  !active &&
                    !exiting &&
                    "pointer-events-none absolute opacity-0",
                  active &&
                    transitionKey > 0 &&
                    "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
                  exiting &&
                    "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
                )}
              >
                {layerContent(layer, active || exiting)}
              </span>
            )
          })}
        </span>
      ) : (
        children
      )}
    </ButtonPrimitive>
  )

  if (!enabled) {
    return button
  }

  const announcement =
    status === "loading"
      ? typeof loadingLabel === "string"
        ? loadingLabel
        : "Loading"
      : status === "success"
        ? typeof successLabel === "string"
          ? successLabel
          : "Done"
        : status === "error"
          ? typeof resolvedErrorLabel === "string"
            ? resolvedErrorLabel
            : "Failed"
          : ""

  return (
    <>
      {button}
      <span data-slot="button-status" role="status" className="sr-only">
        {announcement}
      </span>
    </>
  )
}

export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
```

Update the import paths to match your project setup.

## Usage

UI SDK adds a little CSS of its own in a cascade layer named `ui-sdk`. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

```css
@layer ui-sdk;

@import "tailwindcss";
```

```tsx
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"
```

```tsx
<FileInput multiple accept="image/*" maxSize={5_000_000}>
  <FileInputDropzone>
    <FileInputDropzoneIcon />
    <FileInputDropzoneTitle>Drop files here</FileInputDropzoneTitle>
    <FileInputTrigger>Browse files</FileInputTrigger>
  </FileInputDropzone>
  <FileInputRejections />
  <FileInputList aria-label="Files">
    <FileInputItem />
  </FileInputList>
</FileInput>
```

The file input holds the files people choose, drop or paste, and submits them with a form. Uploading them is up to your app. `FileInputItem` with no children renders a finished row with a preview, the name, the size and a remove button, once for each file in the list.

The checks for `accept`, sizes, counts and `validate` run in the browser only, to tell people early why a file was left out. Anyone can skip them, so check every upload again on your server.

## Composition

```text
FileInput
├── FileInputTrigger
├── FileInputDropzone
│   ├── FileInputDropzoneIcon
│   ├── FileInputDropzoneTitle
│   ├── FileInputDropzoneDescription
│   └── FileInputTrigger
├── FileInputRejections
└── FileInputList
    └── FileInputItem
        ├── FileInputItemPreview
        ├── FileInputItemContent
        │   ├── FileInputItemName
        │   └── FileInputItemSize
        └── FileInputItemRemove
```

## Examples

### Accepted types and limits

`accept` takes MIME types, wildcards and extensions. `maxFiles`, `minSize` and `maxSize` set the limits, and `validate` adds checks of your own. `FileInputRejections` lists each file that was left out with every reason, until the next accepted file.

```tsx title="components/examples/ui-sdk-file-input/limits.tsx"
"use client"

import * as React from "react"

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
  createFileInputController,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputLimits() {
  const [files] = React.useState(createFileInputController)

  return (
    <FileInput
      multiple
      controller={files}
      accept=".pdf"
      maxFiles={3}
      minSize={1}
      maxSize={2_000_000}
      validate={(file) =>
        files.getValue().some((other) => other.name === file.name)
          ? "Already added"
          : null
      }
      className="max-w-md"
    >
      <FileInputDropzone className="min-h-32">
        <FileInputDropzoneTitle>Drop up to 3 PDFs</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          2 MB each, no empty files and no duplicates.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Choose PDFs</FileInputTrigger>
      </FileInputDropzone>
      <FileInputRejections />
      <FileInputList aria-label="PDFs">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputLimits }
```

### Image previews

`FileInputItemPreview` shows an image from an object URL it revokes when the file goes away, and an icon for other types. With `layout="grid"`, each file is a square tile.

```tsx title="components/examples/ui-sdk-file-input/images.tsx"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputImages() {
  return (
    <FileInput
      multiple
      accept="image/*"
      maxFiles={6}
      maxSize={10_000_000}
      className="max-w-md"
    >
      <FileInputDropzone className="min-h-32">
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Add photos</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Up to 6 images, 10 MB each.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Choose photos</FileInputTrigger>
      </FileInputDropzone>
      <FileInputRejections />
      <FileInputList aria-label="Photos" layout="grid">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputImages }
```

### Upload button

A trigger alone opens the system file picker. Without `multiple`, a new file replaces the current one, and the root’s children can be a function of its state.

```tsx title="components/examples/ui-sdk-file-input/button.tsx"
"use client"

import { IconUpload } from "@tabler/icons-react"

import { FileInput, FileInputTrigger } from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputButton() {
  return (
    <FileInput accept=".pdf,.doc,.docx">
      {(state) => (
        <div className="flex min-w-0 items-center gap-3">
          <FileInputTrigger>
            <IconUpload aria-hidden="true" />
            {state.value[0] ? "Replace resume" : "Upload resume"}
          </FileInputTrigger>
          <span className="min-w-0 truncate text-sm text-muted-foreground">
            {state.value[0]?.name ?? "No file chosen"}
          </span>
        </div>
      )}
    </FileInput>
  )
}

export { UiSdkFileInputButton }
```

### Prompt attachments

With `variant="composer"`, the drop zone is the card around a message box, and `layout="chips"` lists files as compact chips. `pasteTarget` takes pasted screenshots from the text area, while pasted text stays text.

```tsx title="components/examples/ui-sdk-file-input/attachments.tsx"
"use client"

import * as React from "react"
import { IconArrowUp, IconPaperclip } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputDropzone,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputAttachments() {
  const message = React.useRef<HTMLTextAreaElement>(null)

  return (
    <FileInput multiple maxFiles={4} maxSize={20_000_000} className="max-w-lg">
      <FileInputDropzone
        variant="composer"
        pasteTarget={() => message.current}
      >
        <FileInputList aria-label="Attachments" layout="chips">
          <FileInputItem />
        </FileInputList>
        <textarea
          ref={message}
          aria-label="Message"
          placeholder="Ask anything, or paste a screenshot"
          rows={2}
          className="field-sizing-content max-h-40 min-h-12 w-full resize-none bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground pointer-coarse:text-touch"
        />
        <div className="flex items-center justify-between">
          <FileInputTrigger
            variant="ghost"
            size="icon-sm"
            aria-label="Attach files"
          >
            <IconPaperclip aria-hidden="true" />
          </FileInputTrigger>
          <Button type="button" size="icon-sm" shape="pill" aria-label="Send">
            <IconArrowUp aria-hidden="true" />
          </Button>
        </div>
      </FileInputDropzone>
      <FileInputRejections />
    </FileInput>
  )
}

export { UiSdkFileInputAttachments }
```

### Page-wide drop

With `target="document"` and `variant="overlay"`, the zone covers the page while files are dragged over the window, and takes files dropped anywhere. A drop on another zone stays with that zone.

```tsx title="components/examples/ui-sdk-file-input/page-drop.tsx"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputPageDrop() {
  return (
    <FileInput multiple className="max-w-md">
      <div className="flex items-center justify-between gap-3">
        <p className="m-0 text-sm text-muted-foreground">
          Drop files anywhere on this page.
        </p>
        <FileInputTrigger>Upload files</FileInputTrigger>
      </div>
      <FileInputDropzone target="document" variant="overlay">
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Drop to attach</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Files land in the list below.
        </FileInputDropzoneDescription>
      </FileInputDropzone>
      <FileInputList aria-label="Attachments">
        <FileInputItem />
      </FileInputList>
    </FileInput>
  )
}

export { UiSdkFileInputPageDrop }
```

### Folders

`directory` picks a whole folder and turns on `multiple`. Item names show the path inside the folder.

```tsx title="components/examples/ui-sdk-file-input/folders.tsx"
import { IconFolder } from "@tabler/icons-react"

import {
  FileInput,
  FileInputItem,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputFolders() {
  return (
    <FileInput directory className="max-w-md">
      <FileInputTrigger className="self-start">
        <IconFolder aria-hidden="true" />
        Choose a folder
      </FileInputTrigger>
      <div className="max-h-72 overflow-y-auto overscroll-contain p-1 empty:hidden">
        <FileInputList aria-label="Folder contents">
          <FileInputItem />
        </FileInputList>
      </div>
    </FileInput>
  )
}

export { UiSdkFileInputFolders }
```

### Controlled

Pass `value` and `onValueChange`. A controller from `createFileInputController()` opens the picker from any button, and clears or adds files.

```tsx title="components/examples/ui-sdk-file-input/controlled.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputItem,
  FileInputList,
  createFileInputController,
  formatFileSize,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputControlled() {
  const [files, setFiles] = React.useState<File[]>([])
  const [picker] = React.useState(createFileInputController)
  const total = files.reduce((sum, file) => sum + file.size, 0)

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex flex-wrap items-center gap-2">
        <Button size="sm" variant="outline" onClick={() => picker.open()}>
          Choose files
        </Button>
        <Button
          size="sm"
          variant="ghost"
          disabled={files.length === 0}
          onClick={() => picker.clear()}
        >
          Clear
        </Button>
        <span className="ms-auto text-sm text-muted-foreground tabular-nums">
          {files.length === 0
            ? "No files"
            : `${files.length} ${files.length === 1 ? "file" : "files"}, ${formatFileSize(total)}`}
        </span>
      </div>
      <FileInput
        multiple
        controller={picker}
        value={files}
        onValueChange={(value) => setFiles(value)}
      >
        <FileInputList aria-label="Chosen files">
          <FileInputItem />
        </FileInputList>
      </FileInput>
    </div>
  )
}

export { UiSdkFileInputControlled }
```

### Form

With a `name`, the files submit through a native file input, even before the page hydrates. Rejected files never submit, and a form reset empties the list.

```tsx title="components/examples/ui-sdk-file-input/form.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputRejections,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputForm() {
  const [submitted, setSubmitted] = React.useState<string[] | null>(null)

  return (
    <form
      className="flex w-full max-w-md flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        setSubmitted(
          data
            .getAll("attachments")
            .flatMap((entry) =>
              entry instanceof File && entry.name ? [entry.name] : []
            )
        )
      }}
      onReset={() => setSubmitted(null)}
    >
      <FileInput name="attachments" multiple maxSize={10_000_000}>
        <FileInputDropzone className="min-h-32">
          <FileInputDropzoneTitle>Attach files</FileInputDropzoneTitle>
          <FileInputDropzoneDescription>
            Up to 10 MB each.
          </FileInputDropzoneDescription>
          <FileInputTrigger className="mt-3">Browse files</FileInputTrigger>
        </FileInputDropzone>
        <FileInputRejections />
        <FileInputList aria-label="Attachments">
          <FileInputItem />
        </FileInputList>
      </FileInput>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          Submit
        </Button>
        <Button type="reset" size="sm" variant="ghost">
          Reset
        </Button>
      </div>
      <p className="m-0 text-sm text-muted-foreground">
        {submitted === null
          ? "Nothing submitted yet."
          : submitted.length
            ? `Submitted ${submitted.join(", ")}`
            : "Submitted with no files."}
      </p>
    </form>
  )
}

export { UiSdkFileInputForm }
```

### Invalid

`invalid` marks the drop zone and the trigger, and the trigger tells screen readers. Point the trigger’s `aria-describedby` at your message.

```tsx title="components/examples/ui-sdk-file-input/invalid.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputInvalid() {
  const [invalid, setInvalid] = React.useState(false)
  const errorId = React.useId()

  return (
    <form
      noValidate
      className="flex w-full max-w-md flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        const proof = data.get("proof")
        setInvalid(!(proof instanceof File && proof.name))
      }}
    >
      <FileInput
        name="proof"
        accept={["image/*", ".pdf"]}
        invalid={invalid}
        onValueChange={(value) => {
          if (value.length) setInvalid(false)
        }}
      >
        <FileInputDropzone className="min-h-32">
          <FileInputDropzoneTitle>Proof of address</FileInputDropzoneTitle>
          <FileInputDropzoneDescription>
            A utility bill or bank statement from the last 3 months.
          </FileInputDropzoneDescription>
          <FileInputTrigger
            className="mt-3"
            aria-describedby={invalid ? errorId : undefined}
          >
            Choose a file
          </FileInputTrigger>
        </FileInputDropzone>
        {invalid ? (
          <p id={errorId} className="m-0 text-sm text-destructive">
            Add a scan to continue.
          </p>
        ) : null}
        <FileInputList aria-label="Proof of address">
          <FileInputItem />
        </FileInputList>
      </FileInput>
      <Button type="submit" size="sm" className="self-start">
        Continue
      </Button>
    </form>
  )
}

export { UiSdkFileInputInvalid }
```

### Disabled

A disabled file input ignores picks, drops and pastes, and submits nothing.

```tsx title="components/examples/ui-sdk-file-input/disabled.tsx"
import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputDisabled() {
  return (
    <FileInput disabled className="max-w-md">
      <FileInputDropzone>
        <FileInputDropzoneIcon />
        <FileInputDropzoneTitle>Uploads are paused</FileInputDropzoneTitle>
        <FileInputDropzoneDescription>
          Drops and pastes are ignored while the file input is disabled.
        </FileInputDropzoneDescription>
        <FileInputTrigger className="mt-3">Browse files</FileInputTrigger>
      </FileInputDropzone>
    </FileInput>
  )
}

export { UiSdkFileInputDisabled }
```

### Right to left

Rows and the drop zone mirror, file names keep their own direction, and sizes format in the locale you pass.

```tsx title="components/examples/ui-sdk-file-input/rtl.tsx"
"use client"

import {
  FileInput,
  FileInputDropzone,
  FileInputDropzoneDescription,
  FileInputDropzoneIcon,
  FileInputDropzoneTitle,
  FileInputItem,
  FileInputItemContent,
  FileInputItemName,
  FileInputItemPreview,
  FileInputItemRemove,
  FileInputItemSize,
  FileInputList,
  FileInputTrigger,
} from "@/components/ui/ui-sdk-file-input"

function UiSdkFileInputRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <FileInput multiple accept="image/*">
        <FileInputDropzone className="min-h-32">
          <FileInputDropzoneIcon />
          <FileInputDropzoneTitle>أفلت الصور هنا</FileInputDropzoneTitle>
          <FileInputDropzoneDescription>
            أو الصقها من الحافظة.
          </FileInputDropzoneDescription>
          <FileInputTrigger className="mt-3">تصفح الملفات</FileInputTrigger>
        </FileInputDropzone>
        <FileInputList aria-label="الصور">
          <FileInputItem>
            {({ file }) => (
              <>
                <FileInputItemPreview />
                <FileInputItemContent>
                  <FileInputItemName />
                  <FileInputItemSize locale="ar" />
                </FileInputItemContent>
                <FileInputItemRemove aria-label={`إزالة ${file.name}`} />
              </>
            )}
          </FileInputItem>
        </FileInputList>
      </FileInput>
    </div>
  )
}

export { UiSdkFileInputRtl }
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves to the trigger, then into the list, onto the last focused file or the first one. |
| `Enter` `Space` | Opens the file picker from the trigger. |
| `↑` `↓` `←` `→` | Moves between files in the list. |
| `Home` `End` | Moves to the first or last file. |
| `Delete` `Backspace` | Removes the focused file and focuses the next one. |
| `⌘V` | Pastes files while focus is inside the drop zone or its paste target. |

## Accessibility

The drop zone isn’t a control, so always keep a trigger beside it for people who use a keyboard or a screen reader. The list is one tab stop, and each remove button is named for its file, like “Remove photo.png”, and stays out of the tab order because Delete on the item does the same.

Accepted, removed and rejected files are announced through a polite live region in English. Pass `announcement` to say it in your language, and `getReasonLabel` to `FileInputRejections` for its reasons. The drop zone icon only lifts when motion is allowed, and new rows fade in only then too.

## API reference

Each part wraps the UI SDK part of the same name and accepts all of its props, including `render` and a `className` that can be a function of the part’s state.

### FileInput

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `File[]` | – |  |
| `defaultValue` | `File[]` | `[]` |  |
| `onValueChange` | `(value: File[], details) => void` | – | details has the reason and the added, removed and rejected files. |
| `onReject` | `(rejected: FileRejection[], details) => void` | – | Runs when files fail the checks, even when none were accepted. |
| `accept` | `string \| string[]` | – |  |
| `multiple` | `boolean` | `false` |  |
| `maxFiles` | `number` | – |  |
| `maxSize` | `number` | – | In bytes. |
| `minSize` | `number` | – | In bytes. |
| `validate` | `(file: File) => string \| null` | – | Return a reason to reject the file, after the built-in checks. |
| `directory` | `boolean` | `false` |  |
| `capture` | `"user" \| "environment" \| boolean` | – | Opens the camera on phones. |
| `invalid` | `boolean` | `false` |  |
| `disabled` | `boolean` | `false` |  |
| `required` | `boolean` | `false` |  |
| `name` | `string` | – |  |
| `form` | `string` | – |  |
| `announcement` | `(change) => string \| null` | – | What screen readers hear after a change. |
| `controller` | `FileInputController` | – | From createFileInputController(). |
| `children` | `ReactNode \| (state) => ReactNode` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="file-input"` | Target the file input in CSS. |
| `data-dragging` | Present while files are dragged over one of its drop zones. |
| `data-invalid` | Present when invalid. |
| `data-required` | Present when a file is required. |
| `data-disabled` | Present when disabled. |

### FileInputDropzone

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "composer" \| "overlay"` | `"default"` | A dashed zone, a card that wraps a message composer, or a full-page overlay shown while dragging. |
| `target` | `"element" \| "document"` | `"element"` |  |
| `pasteTarget` | `"document" \| string \| Element \| () => Element` | – | Somewhere else to take pasted files from. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="file-input-dropzone"` | Target the drop zone in CSS. |
| `data-variant` | The variant: default, composer or overlay. |
| `data-dragging` | Present while files are dragged over it. |
| `data-drag-accept` | Present while the dragged files look acceptable, from their types and count. |
| `data-drag-reject` | Present while a dragged file has a type that isn’t accepted, or there are too many. |
| `data-disabled` | Present when disabled. |

### FileInputTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `Button variant` | `"outline"` |  |
| `size` | `Button size` | `"sm"` |  |
| `controller` | `FileInputController` | – | Connects a trigger outside the file input, like one in a toolbar. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

### FileInputList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `layout` | `"list" \| "grid" \| "chips"` | `"list"` | Rows, square image tiles, or compact chips. Items take their look from it. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="file-input-list"` | Target the list in CSS. |
| `data-layout` | The layout: list, grid or chips. Items carry it too. |

### FileInputItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode \| (state) => ReactNode` | – | Defaults to a preview, the name, the size and a remove button. The state has the file and its index. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="file-input-item"` | Target a file’s row in CSS. |
| `data-disabled` | Present when disabled. |

### FileInputItemPreview

| Attribute | Description |
| --- | --- |
| `data-slot="file-input-item-preview"` | Target the preview in CSS. |
| `data-kind` | image for an image preview, icon for an icon by file type. |

### FileInputItemSize

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `locale` | `string` | – | Formats the size, like 2.4 kB, in this locale. Defaults to the browser’s. |

### FileInputItemRemove

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | Defaults to an X icon. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

### FileInputRejections

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `getReasonLabel` | `(reason, rules, rejection) => string` | – | Names each reason. Defaults to English, like “Larger than 5 MB”. |

| Attribute | Description |
| --- | --- |
| `data-slot="file-input-rejections"` | Target the list of rejected files in CSS. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt

## Related

- [Button](https://hextaui.com/docs/button) - Buttons in every variant and size, with a built-in loading, success and error flow that skips the spinner for fast requests.
- [Calendar](https://hextaui.com/docs/calendar) - A date grid for single, range and multiple selection, with sliding months, range previews and touch-sized days.
- [Checkbox](https://hextaui.com/docs/checkbox) - A checkbox whose check draws in, with indeterminate parents, groups and labels that share its hover.
- [Combobox](https://hextaui.com/docs/combobox) - A filterable select with chips, groups and async results, in a popup that resizes as you type.
- [Date picker](https://hextaui.com/docs/date-picker) - A button that opens a calendar in a popover, or a bottom sheet on phones, for single dates and ranges.
- [Field](https://hextaui.com/docs/field) - Labels, descriptions and errors wired to their control, with validation states and layouts for forms.
