# Image Metadata

> Images upload in the background while people write alt text and captions.

A shadcn/ui block from the [@uploadcn](https://uploadcn.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uploadcn/image-metadata).

- Page, with a live preview: https://shadcn.exchange/r/uploadcn/image-metadata
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uploadcn/image-metadata-light-6b50f4ed6080acab.png
- Registry item: https://uploadcn.dev/r/new-york-v4/image-metadata.json
- Categories: upload, image, forms
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/image-metadata.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@uploadcn/core`, `@uploadcn/react`
- Registry items: `@uploadcn/upload`, `badge`, `input`, `textarea`

## Files

### `registry/default/blocks/image-metadata.tsx`

```tsx
"use client"

import * as React from "react"
import {
  type UploadAdapter,
  type Uploader,
  type UploadItem as UploadItemData,
} from "@uploadcn/core"
import {
  UploadItemContext,
  useUploadContext,
  useUploadSelector,
  type UploadRootProps,
} from "@uploadcn/react"

import { IconPlaceholder } from "@/components/icon-placeholder"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
  NativeButton,
  Upload,
  UploadDropzone,
  UploadDropzoneDescription,
  UploadDropzoneHeader,
  UploadDropzoneMedia,
  UploadDropzoneTitle,
  UploadFooter,
  UploadItemActions,
  UploadItemMedia,
  UploadItemProgress,
  UploadItemStatus,
  UploadSummary,
  UploadTrigger,
} from "@/registry/default/ui/upload"

export interface ImageMetadata<TResult = unknown> {
  item: UploadItemData<TResult>
  alt: string
  caption: string
}

interface ImageMetadataUploadProps<TResult> {
  adapter?: UploadAdapter<TResult>
  /** Use an uploader you created with `useUploader` instead of options. */
  uploader?: Uploader<TResult>
  /** Default 8. */
  maxFiles?: number
  /** Require alt text before saving. Default `true`. */
  requireAlt?: boolean
  onSave?: (images: ImageMetadata<TResult>[]) => void
  className?: string
}

/**
 * Images upload in the background while people write alt text and
 * captions. Saving is blocked until every image has a description.
 */
function ImageMetadataUpload<TResult = unknown>({
  adapter,
  uploader,
  maxFiles = 8,
  requireAlt = true,
  onSave,
  className,
}: ImageMetadataUploadProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : {
            adapter,
            accept: "image/*",
            maxFiles,
          }) as UploadRootProps<TResult>)}
      className={className}
    >
      <Editor
        requireAlt={requireAlt}
        onSave={onSave as ((images: ImageMetadata[]) => void) | undefined}
      />
    </Upload>
  )
}

function Editor({
  requireAlt,
  onSave,
}: {
  requireAlt: boolean
  onSave?: (images: ImageMetadata[]) => void
}) {
  const { uploader } = useUploadContext()
  const items = useUploadSelector(uploader, (state) =>
    state.items.filter(
      (item) => item.status !== "rejected" && item.status !== "cancelled"
    )
  )
  const missing = items.filter((item) => !String(item.meta.alt ?? "").trim())
  const pending = items.some((item) => item.status !== "success")

  if (items.length === 0) {
    return (
      <UploadDropzone>
        <UploadDropzoneHeader>
          <UploadDropzoneMedia variant="icon">
            <IconPlaceholder
              lucide="ImagePlusIcon"
              tabler="IconPhotoPlus"
              hugeicons="ImageAdd01Icon"
              phosphor="ImageIcon"
              remixicon="RiImageAddLine"
            />
          </UploadDropzoneMedia>
          <UploadDropzoneTitle>Add images</UploadDropzoneTitle>
          <UploadDropzoneDescription>
            Describe each one while it uploads
          </UploadDropzoneDescription>
        </UploadDropzoneHeader>
      </UploadDropzone>
    )
  }

  return (
    <>
      <ul className="flex flex-col gap-3">
        {items.map((item) => (
          <UploadItemContext.Provider key={item.id} value={item}>
            <MetadataRow item={item} requireAlt={requireAlt} />
          </UploadItemContext.Provider>
        ))}
      </ul>
      <UploadFooter className="justify-between">
        <div className="flex flex-col gap-0.5">
          <UploadSummary />
          {requireAlt && missing.length > 0 ? (
            <span className="text-xs text-muted-foreground">
              {missing.length} image{missing.length === 1 ? "" : "s"} still need
              {missing.length === 1 ? "s" : ""} alt text
            </span>
          ) : null}
        </div>
        <div className="flex gap-2">
          <UploadTrigger variant="ghost" size="sm">
            Add more
          </UploadTrigger>
          <NativeButton
            size="sm"
            disabled={pending || (requireAlt && missing.length > 0)}
            onClick={() =>
              onSave?.(
                items.map((item) => ({
                  item,
                  alt: String(item.meta.alt ?? "").trim(),
                  caption: String(item.meta.caption ?? "").trim(),
                }))
              )
            }
          >
            Save images
          </NativeButton>
        </div>
      </UploadFooter>
    </>
  )
}

function MetadataRow({
  item,
  requireAlt,
}: {
  item: UploadItemData
  requireAlt: boolean
}) {
  const { uploader } = useUploadContext()
  const alt = String(item.meta.alt ?? "")
  const caption = String(item.meta.caption ?? "")
  const altId = `${item.id}-alt`
  const captionId = `${item.id}-caption`

  return (
    <li className="ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) rounded-xl text-sm ring-1 [--card-spacing:--spacing(4)] p-(--card-spacing) flex animate-in flex-col gap-3 p-3 fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none sm:flex-row">
      <div className="flex flex-col gap-1.5 sm:w-32">
        <div className="relative aspect-4/3 w-full overflow-hidden rounded-lg border">
          <UploadItemMedia variant="cover" />
        </div>
        <UploadItemProgress />
        <UploadItemStatus className="sm:hidden" />
      </div>
      <div className="flex min-w-0 flex-1 flex-col gap-2.5">
        <div className="flex min-w-0 items-center gap-2">
          <span className="truncate text-sm font-medium">{item.name}</span>
          <UploadItemStatus showIcon className="max-sm:hidden" />
          <UploadItemActions className="ms-auto" />
        </div>
        <div className="flex flex-col gap-1.5">
          <div className="flex items-center gap-2">
            <label htmlFor={altId} className="text-xs font-medium">
              Alt text
            </label>
            {requireAlt && !alt.trim() ? (
              <Badge variant="outline" className="gap-1 text-xs">
                <IconPlaceholder
                  lucide="TriangleAlertIcon"
                  tabler="IconAlertTriangle"
                  hugeicons="Alert02Icon"
                  phosphor="WarningIcon"
                  remixicon="RiErrorWarningLine"
                />
                Required
              </Badge>
            ) : null}
            <span className="ms-auto text-xs text-muted-foreground tabular-nums">
              {alt.length}/125
            </span>
          </div>
          <Input
            id={altId}
            maxLength={125}
            placeholder="Describe what's in the image"
            value={alt}
            onChange={(event) =>
              uploader.update(item.id, { meta: { alt: event.target.value } })
            }
          />
        </div>
        <div className="flex flex-col gap-1.5">
          <label htmlFor={captionId} className="text-xs font-medium">
            Caption <span className="text-muted-foreground">(optional)</span>
          </label>
          <Textarea
            id={captionId}
            rows={2}
            className="min-h-0 resize-none"
            placeholder="Shown under the image"
            value={caption}
            onChange={(event) =>
              uploader.update(item.id, {
                meta: { caption: event.target.value },
              })
            }
          />
        </div>
      </div>
    </li>
  )
}

export { ImageMetadataUpload, type ImageMetadataUploadProps }
```
