ImageInput
FormValidated raster image selection with local previews, replacement, and removal.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { ImageInput } from "@/components/ui/image-input";
import { useState } from "react";
export default function ImageInputExample() {
const [cover, setCover] = useState<File | null>(null);
const [avatar, setAvatar] = useState<File | null>(null);
return (
<div className="w-full max-w-xl space-y-8">
<div>
<h3 className="font-semibold">Make your workspace recognizable</h3>
<p className="mt-1 text-sm text-muted-foreground">
Add a cover and profile image. Preview changes before saving.
</p>
</div>
<ImageInput
label="Workspace cover"
value={cover}
onValueChange={setCover}
shape="landscape"
placeholderSrc="/images/component-demos/coast.svg"
/>
<div className="grid gap-6 sm:grid-cols-2">
<ImageInput
label="Profile photo"
value={avatar}
onValueChange={setAvatar}
shape="circle"
maxSize={2 * 1024 * 1024}
/>
<ImageInput
label="Managed brand mark"
value={null}
onValueChange={() => {}}
disabled
/>
</div>
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible name for the input and preview. |
| value | File | nullDefault: required | Controlled selected raster image. |
| onValueChange | (file: File | null) => voidDefault: required | Commit a decoded image or clear the selection. |
| maxSize | numberDefault: 5242880 | Maximum allowed bytes. |
| shape | square | circle | landscapeDefault: square | Preview geometry; uses object-cover rather than modifying the original file. |
| placeholderSrc | stringDefault: — | Display fallback when no file is selected. |
| disabled | booleanDefault: false | Disable selection and removal. |
| className | stringDefault: — | Extend the container with semantic theme classes. |