Skip to content

ImageInput

Form

Validated raster image selection with local previews, replacement, and removal.

Landscape coverCircular profileSquare brand markInvalid/empty/oversize rejectionDisabled input
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Own the selected File or null and upload it only when your application is ready. Selection accepts PNG, JPEG, and WebP, checks the byte limit, and verifies that the browser can decode the file before committing it. Invalid selection preserves the previous file. Decoding object URLs are revoked after checking, and preview file reads are cancelled on replacement and unmount. placeholderSrc is a display fallback, not a selected file. The component does not crop images or perform uploads; validate files again on your server.

Import
import { ImageInput } from "@/components/ui/image-input";

Shipped as editable source in src/components/ui/image-input.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/image-input.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { ImagePlus, Trash2 } from "lucide-react";
import NextImage from "next/image";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ImageInputProps {
  label: string;
  value: File | null;
  onValueChange: (file: File | null) => void;
  maxSize?: number;
  shape?: "square" | "circle" | "landscape";
  disabled?: boolean;
  placeholderSrc?: string;
  className?: string;
}
export function validateImageFile(file: File, maxSize: number) {
  if (!["image/png", "image/jpeg", "image/webp"].includes(file.type))
    return "Choose a PNG, JPEG, or WebP image.";
  if (file.size > maxSize)
    return `Choose an image smaller than ${(maxSize / 1024 / 1024).toFixed(1)} MB.`;
  if (!file.size) return "The selected file is empty.";
  return null;
}
export function ImageInput({
  label,
  value,
  onValueChange,
  maxSize = 5 * 1024 * 1024,
  shape = "square",
  disabled,
  placeholderSrc,
  className,
}: ImageInputProps) {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const request = useRef(0);
  const [preview, setPreview] = useState<{ file: File; url: string } | null>(
    null,
  );
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);
  useEffect(() => {
    if (!value) return;
    const reader = new FileReader();
    reader.onload = () => {
      if (typeof reader.result === "string")
        setPreview({ file: value, url: reader.result });
    };
    reader.onerror = () => setError("This file could not be read.");
    reader.readAsDataURL(value);
    return () => {
      if (reader.readyState === FileReader.LOADING) reader.abort();
    };
  }, [value]);
  useEffect(
    () => () => {
      request.current++;
    },
    [],
  );
  const choose = async (file: File | undefined) => {
    if (!file || disabled) return;
    const token = ++request.current;
    const invalid = validateImageFile(file, maxSize);
    if (invalid) {
      setError(invalid);
      return;
    }
    setBusy(true);
    const url = URL.createObjectURL(file);
    const image = new Image();
    const decoded = await new Promise<boolean>((resolve) => {
      image.onload = () => resolve(true);
      image.onerror = () => resolve(false);
      image.src = url;
    });
    URL.revokeObjectURL(url);
    if (token !== request.current) return;
    setBusy(false);
    if (!decoded) {
      setError("This file could not be decoded as an image.");
      return;
    }
    setError("");
    onValueChange(file);
  };
  const src =
    value && preview?.file === value
      ? preview.url
      : !value
        ? placeholderSrc
        : undefined;
  return (
    <div className={cn("min-w-0 space-y-3", className)}>
      <p id={`${id}-label`} className="text-sm font-medium">
        {label}
      </p>
      <div
        aria-labelledby={`${id}-label`}
        aria-busy={busy}
        className={cn(
          "group relative flex items-center justify-center overflow-hidden border border-dashed bg-muted/30",
          shape === "landscape"
            ? "aspect-video w-full max-w-sm rounded-xl"
            : shape === "circle"
              ? "size-32 rounded-full"
              : "size-40 rounded-xl",
        )}
      >
        {src ? (
          <NextImage
            unoptimized
            fill
            sizes="384px"
            src={src}
            alt={`${label} preview`}
            className="size-full object-cover"
          />
        ) : (
          <ImagePlus aria-hidden className="size-8 text-muted-foreground" />
        )}
      </div>
      <input
        ref={input}
        id={id}
        type="file"
        accept="image/png,image/jpeg,image/webp"
        aria-label={`Upload ${label}`}
        aria-describedby={`${id}-hint${error ? ` ${id}-error` : ""}`}
        disabled={disabled || busy}
        className="sr-only"
        onChange={(event) => {
          void choose(event.target.files?.[0]);
          event.target.value = "";
        }}
      />
      <div className="flex flex-wrap gap-2">
        <Button
          type="button"
          variant="outline"
          size="sm"
          disabled={disabled || busy}
          onClick={() => input.current?.click()}
        >
          <ImagePlus className="size-4" />
          {busy ? "Checking image…" : value ? "Replace image" : "Choose image"}
        </Button>
        <Button
          type="button"
          variant="ghost"
          size="sm"
          disabled={disabled || busy || !value}
          onClick={() => {
            request.current++;
            setError("");
            onValueChange(null);
          }}
        >
          <Trash2 className="size-4" />
          Remove image
        </Button>
      </div>
      <p id={`${id}-hint`} className="text-xs text-muted-foreground">
        PNG, JPEG, or WebP · Up to {(maxSize / 1024 / 1024).toFixed(1)} MB.
        Files stay on this device until your app uploads them.
      </p>
      {value && (
        <p className="break-all text-xs text-muted-foreground">{value.name}</p>
      )}
      {error && (
        <p role="alert" id={`${id}-error`} className="text-sm text-destructive">
          {error}
        </p>
      )}
    </div>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

ImageInput props
PropType / DefaultDescription
labelstring

Default: required

Visible name for the input and preview.
valueFile | null

Default: required

Controlled selected raster image.
onValueChange(file: File | null) => void

Default: required

Commit a decoded image or clear the selection.
maxSizenumber

Default: 5242880

Maximum allowed bytes.
shapesquare | circle | landscape

Default: square

Preview geometry; uses object-cover rather than modifying the original file.
placeholderSrcstring

Default: —

Display fallback when no file is selected.
disabledboolean

Default: false

Disable selection and removal.
classNamestring

Default: —

Extend the container with semantic theme classes.

Build with these too