Qure UI
Components

File Upload

A drop target and a picker trigger, because a file input cannot be styled.

'use client'import * as React from 'react'import { CloudUploadIcon } from 'lucide-react'import { FileDropzone } from '@/registry/qure/ui/file-upload'export default function FileUploadDemo() {  const [files, setFiles] = React.useState<File[]>([])  return (    <div className="w-96">      <FileDropzone accept=".dcm,.zip" multiple onSelect={setFiles}>        <CloudUploadIcon className="cn-file-dropzone-icon size-7" />        <span className="cn-file-dropzone-label">Click or drop to upload</span>        <span className="cn-file-dropzone-hint">DICOM or ZIP · up to 200 MB</span>      </FileDropzone>      {files.length > 0 ? (        <ul className="text-body-small text-muted-foreground mt-3 space-y-1">          {files.map((f) => (            <li key={f.name} className="truncate">              {f.name}            </li>          ))}        </ul>      ) : null}    </div>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/file-upload.json

Usage

import { FileDropzone, FileTrigger } from "@/components/ui/file-upload"

Two components over the same hidden input. FileDropzone is the bordered area; FileTrigger turns a control you already have into a picker.

A trigger on your own control

PDF only.
'use client'import * as React from 'react'import { PaperclipIcon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import { FileTrigger } from '@/registry/qure/ui/file-upload'export default function FileUploadTrigger() {  const [name, setName] = React.useState<string | null>(null)  return (    <div className="flex flex-col items-start gap-2">      {/* The trigger is whatever you render — here a secondary Button, which          is how the app does it. FileTrigger only supplies the click. */}      <FileTrigger        accept="application/pdf"        onSelect={(files) => setName(files[0]?.name ?? null)}        render={<Button variant="secondary" size="sm" />}      >        <PaperclipIcon />        {name ?? 'Choose file'}      </FileTrigger>      <span className="text-label-base text-muted-foreground">        {name ? 'Pick the same file again — it still fires.' : 'PDF only.'}      </span>    </div>  )}
<FileTrigger
  accept="application/pdf"
  onSelect={(files) => importPdf(files[0])}
  render={<Button variant="secondary" size="sm" />}
>
  <PaperclipIcon />
  Choose file
</FileTrigger>

onSelect receives a File[] and is never called with an empty one, so there is no cancelled- picker case to handle.

Picking the same file twice works. A file input fires change only when its value changes, so re-picking the file you just picked is normally silent — which reads as the upload having failed. The input is cleared after every selection, so the second pick fires like the first.

While it uploads

'use client'import * as React from 'react'import { CloudUploadIcon } from 'lucide-react'import { FileDropzone } from '@/registry/qure/ui/file-upload'import { Spinner } from '@/registry/qure/ui/spinner'export default function FileUploadUploading() {  const [busy, setBusy] = React.useState(false)  return (    <div className="w-96">      {/* Disabled while the upload runs. A drop target that still highlights          during an upload invites a second file it is going to drop on the          floor. */}      <FileDropzone        accept=".dcm"        disabled={busy}        onSelect={() => {          setBusy(true)          setTimeout(() => setBusy(false), 2500)        }}      >        {busy ? (          <>            <Spinner size="lg" label={null} className="text-primary" />            <span className="cn-file-dropzone-label">Uploading…</span>          </>        ) : (          <>            <CloudUploadIcon className="cn-file-dropzone-icon size-7" />            <span className="cn-file-dropzone-label">Click or drop to upload</span>            <span className="cn-file-dropzone-hint">DICOM · up to 200 MB</span>          </>        )}      </FileDropzone>    </div>  )}

Disable the zone while a transfer is running. A target that still highlights during an upload invites a second file it is going to drop on the floor.

Composition

The dropzone's contents are yours; three class names are provided so they take the right type and colour, including when the zone is disabled.

<FileDropzone accept=".dcm,.zip" multiple onSelect={setFiles}>
  <CloudUploadIcon className="cn-file-dropzone-icon size-7" />
  <span className="cn-file-dropzone-label">Click or drop to upload</span>
  <span className="cn-file-dropzone-hint">DICOM or ZIP · up to 200 MB</span>
</FileDropzone>

Accessibility

The dropzone is a real button. Dragging cannot be done from a keyboard, cannot be done on a touch screen, and gives no on-screen sign that it is possible. Dropping is added on top of a control that works without it — so Tab reaches it, Enter and Space open the picker, and the focus ring is the same brand border as hover.

The input is clipped, not hidden. display: none and visibility: hidden both take it out of the accessibility tree, which leaves a keyboard user with a button that opens a dialog they were never told about. It is clipped to one pixel instead, so it stays announced.

accept is a filter on the picker, not a guarantee. A file can still arrive by drop with any type at all, so validate on selection and again on the server — never treat the extension as proof of what a file is.

API Reference

Both components take the same props.

Prop

Type

On this page