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-uploadnpx newtui@latest add file-uploadyarn dlx newtui@latest add file-uploadbunx newtui@latest add file-uploadCopy 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
children / default slot?React.ReactNode / Vue slot
Content rendered inside the component.
React.ReactNode / Vue slotclassName / class?string
Additional classes merged with the component defaults.
stringAccessibility
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.