Skip to content

File Upload

A drop zone that also opens a file picker from the keyboard.

A drop zone that also opens a file picker from the keyboard. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

"use client"

import * as React from "react"

import { FileUpload } from "@/registry/bases/newt/ui/file-upload"

export default function FileUploadDemo() {
  const [chosen, setChosen] = React.useState<readonly string[]>([])

  return (
    <div className="flex w-full max-w-[420px] flex-col gap-2">
      <FileUpload
        multiple
        hint="Up to 10 files under 500 MB."
        onFiles={(files) => setChosen(files.map((file) => file.name))}
      />
      {chosen.length === 0 ? null : (
        <p className="text-xs text-newt-text-muted">{chosen.join(", ")}</p>
      )}
    </div>
  )
}
<script setup lang="ts">
import { ref } from "vue"
import { FileUpload } from "~~/registry/bases/newt/ui/file-upload"

const chosen = ref<readonly string[]>([])

function onFiles(files: readonly File[]) {
  chosen.value = files.map((file) => file.name)
}
</script>

<template>
  <div class="flex w-full max-w-[420px] flex-col gap-2">
    <FileUpload multiple hint="Up to 10 files under 500 MB." @files="onFiles" />
    <p v-if="chosen.length" class="text-xs text-newt-text-muted">
      {{ chosen.join(", ") }}
    </p>
  </div>
</template>

Installation

pnpm dlx newtui@latest add file-upload
npx newtui@latest add file-upload
yarn dlx newtui@latest add file-upload
bunx newtui@latest add file-upload

Copy the source into your project

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

/*
 * The drop zone a modal asks for a file with. Two ways in, because a drop is
 * not available to everyone: dragging files onto it, and a `browse` control
 * that opens the same picker from the keyboard.
 *
 * The file input stays in the DOM rather than being replaced by a button —
 * it is the accessible name, the keyboard path and the picker all at once.
 */
export interface FileUploadProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  "onDrop" | "onChange"
> {
  /** Accepted types, passed straight to the input. */
  accept?: string
  /** Whether more than one file can be chosen at a time. */
  multiple?: boolean
  disabled?: boolean
  /** The line under the prompt: how many, how large. */
  hint?: React.ReactNode
  /** Called with whatever was dropped or picked. */
  onFiles?: (files: readonly File[]) => void
}

const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
  (
    {
      className,
      accept,
      multiple = false,
      disabled = false,
      hint,
      onFiles,
      ...props
    },
    ref
  ) => {
    const inputRef = React.useRef<HTMLInputElement>(null)
    const [dragging, setDragging] = React.useState(false)

    function accept_(list: FileList | null) {
      if (list === null || list.length === 0) return
      onFiles?.(Array.from(list))
    }

    return (
      <div
        ref={ref}
        data-dragging={dragging || undefined}
        data-disabled={disabled || undefined}
        onDragOver={(event) => {
          if (disabled) return
          event.preventDefault()
          setDragging(true)
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(event) => {
          if (disabled) return
          event.preventDefault()
          setDragging(false)
          accept_(event.dataTransfer.files)
        }}
        className={cn(
          "flex flex-col items-center justify-center gap-1 rounded-md border border-dashed border-newt-border bg-newt-bg-base px-4 py-6 text-center",
          "transition-colors duration-fast ease-newt",
          "data-[dragging]:border-newt-brand data-[dragging]:bg-newt-bg-surface",
          "focus-within:border-newt-brand",
          disabled && "cursor-not-allowed opacity-50",
          className
        )}
        {...props}
      >
        <svg
          viewBox="0 0 24 24"
          aria-hidden="true"
          className="h-6 w-6 text-newt-text-muted"
        >
          <path
            fill="currentColor"
            d="M12 3a1 1 0 0 1 .7.3l4 4a1 1 0 1 1-1.4 1.4L13 6.42V15a1 1 0 1 1-2 0V6.41L8.71 8.71a1 1 0 0 1-1.42-1.42l4-4A1 1 0 0 1 12 3ZM5 15a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2a1 1 0 1 1 2 0v2a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-2a1 1 0 0 1 1-1Z"
          />
        </svg>

        <p className="text-sm text-newt-text-secondary">
          Drop files here or {/*
           * The label is the control: clicking the word opens the picker, and
           * the input keeps the focus ring and the keyboard behaviour it was
           * born with.
           */}
          <label className="cursor-pointer text-newt-text-link underline-offset-2 hover:underline">
            browse
            <input
              ref={inputRef}
              type="file"
              accept={accept}
              multiple={multiple}
              disabled={disabled}
              className="sr-only"
              onChange={(event) => accept_(event.target.files)}
            />
          </label>
        </p>

        {hint === undefined ? null : (
          <p className="text-xs text-newt-text-muted">{hint}</p>
        )}
      </div>
    )
  }
)
FileUpload.displayName = "FileUpload"

export { FileUpload }
<script setup lang="ts">
import { ref, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

/*
 * The drop zone a modal asks for a file with. Two ways in, because a drop is
 * not available to everyone: dragging files onto it, and a `browse` control
 * that opens the same picker from the keyboard.
 *
 * The file input stays in the DOM rather than being replaced by a button —
 * it is the accessible name, the keyboard path and the picker all at once.
 */
const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /** Accepted types, passed straight to the input. */
    accept?: string
    /** Whether more than one file can be chosen at a time. */
    multiple?: boolean
    disabled?: boolean
    /** The line under the prompt: how many, how large. */
    hint?: string
  }>(),
  { multiple: false, disabled: false }
)

const emit = defineEmits<{ (e: "files", value: readonly File[]): void }>()

const dragging = ref(false)

function take(list: FileList | null) {
  if (list === null || list.length === 0) return
  emit("files", Array.from(list))
}

function onDragOver(event: DragEvent) {
  if (props.disabled) return
  event.preventDefault()
  dragging.value = true
}

function onDrop(event: DragEvent) {
  if (props.disabled) return
  event.preventDefault()
  dragging.value = false
  take(event.dataTransfer?.files ?? null)
}

function onChange(event: Event) {
  // SAFETY: the handler is bound to the file input below, so the target is
  // that input and nothing else can raise this event.
  take((event.target as HTMLInputElement).files)
}
</script>

<template>
  <div
    :data-dragging="dragging ? true : undefined"
    :data-disabled="props.disabled ? true : undefined"
    :class="
      cn(
        'flex flex-col items-center justify-center gap-1 rounded-md border border-dashed border-newt-border bg-newt-bg-base px-4 py-6 text-center',
        'transition-colors duration-fast ease-newt',
        'data-[dragging]:border-newt-brand data-[dragging]:bg-newt-bg-surface',
        'focus-within:border-newt-brand',
        props.disabled && 'cursor-not-allowed opacity-50',
        props.class
      )
    "
    @dragover="onDragOver"
    @dragleave="dragging = false"
    @drop="onDrop"
  >
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      class="h-6 w-6 text-newt-text-muted"
    >
      <path
        fill="currentColor"
        d="M12 3a1 1 0 0 1 .7.3l4 4a1 1 0 1 1-1.4 1.4L13 6.42V15a1 1 0 1 1-2 0V6.41L8.71 8.71a1 1 0 0 1-1.42-1.42l4-4A1 1 0 0 1 12 3ZM5 15a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2a1 1 0 1 1 2 0v2a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-2a1 1 0 0 1 1-1Z"
      />
    </svg>

    <p class="text-sm text-newt-text-secondary">
      Drop files here or
      <!--
        The label is the control: clicking the word opens the picker, and the
        input keeps the focus ring and the keyboard behaviour it was born with.
      -->
      <label
        class="cursor-pointer text-newt-text-link underline-offset-2 hover:underline"
      >
        browse
        <input
          type="file"
          :accept="props.accept"
          :multiple="props.multiple"
          :disabled="props.disabled"
          class="sr-only"
          @change="onChange"
        />
      </label>
    </p>

    <p v-if="props.hint" class="text-xs text-newt-text-muted">
      {{ props.hint }}
    </p>
  </div>
</template>
export { default as FileUpload } from "./FileUpload.vue"

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { FileUpload } from "@/components/ui/file-upload"

export function Example() {
  return <FileUpload />
}
<script setup lang="ts">
import { FileUpload } from "@/components/ui/file-upload"
</script>

<template>
  <FileUpload />
</template>

API reference

FileUpload

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

File Upload reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.