Avatar Upload
Click or drop an image, crop it to a circle or square with zoom, and preview the result. Initials fallback, size and type limits, remove control.
Example
Installation
npx shadcn@latest add https://hirael.com/r/avatar-upload.jsonUsage
import {
AvatarUpload,
AvatarUploadPreview,
AvatarUploadTrigger,
AvatarUploadInput,
AvatarUploadRemove,
AvatarUploadCropDialog,
AvatarUploadError,
} from "@/components/avatar-upload"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<AvatarUpload />
+ native element props| Prop | Type | Default |
|---|---|---|
id | string | none |
valueData URL of the current image. | string | null | none |
defaultValue | string | null | null |
onValueChange | ((value: string | null) => void) | none |
shape | AvatarUploadShape | 'circle' |
sizePreview edge in px. | number | 96 |
maxSizeMax file size in bytes. | number | none |
acceptMIME types mapped to extensions. Defaults to any image. | Accept | IMAGE_TYPES |
outputSizeEdge of the exported square, in px. | number | 512 |
cropSet false to skip the crop dialog and use the file as-is. | boolean | true |
disabled | boolean | none |
onError | ((error: AvatarUploadErrorInfo) => void) | none |
<AvatarUploadPreview />
+ native element props| Prop | Type | Default |
|---|---|---|
fallbackInitials or a short label shown when there is no image. | React.ReactNode | none |
alt | string | 'Avatar' |
<AvatarUploadTrigger />
+ native element props| Prop | Type | Default |
|---|---|---|
variant`overlay` sits on top of the preview; `button` is a plain control. | "button" | "overlay" | 'overlay' |
<AvatarUploadInput />
+ native element propsNo props of its own; forwards everything to the underlying element.
<AvatarUploadRemove />
+ native element propsNo props of its own; forwards everything to the underlying element.
<AvatarUploadCropDialog />
+ native element props| Prop | Type | Default |
|---|---|---|
title | string | 'Adjust your photo' |
description | string | 'Drag to reposition. Scroll or use the slider to zoom.' |
confirmLabel | string | 'Apply' |
cancelLabel | string | 'Cancel' |
zoomLabel | string | 'Zoom' |
maxZoom | number | 3 |
showCloseButton | boolean | none |
<AvatarUploadError />
+ native element props| Prop | Type | Default |
|---|---|---|
messagesOverride the message per reason. | Partial<Record<"type" | "size" | "read", string>> | none |
Component source
'use client';
import * as React from 'react';
import { useDropzone, type Accept, type DropzoneState } from 'react-dropzone';
import { Camera, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/registry/hirael/bases/radix/ui/dialog';
import {
ImageCropper,
ImageCropperZoom,
type ImageCropperRef,
} from '@/registry/hirael/bases/radix/components/image-cropper';
import { formatBytes } from '@/registry/hirael/lib/format-bytes';
const readAsDataUrl = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
};
const IMAGE_TYPES: Accept = { 'image/*': [] };
export type AvatarUploadShape = 'circle' | 'square';
export interface AvatarUploadErrorInfo {
reason: 'type' | 'size' | 'read';
message: string;
}
interface AvatarUploadContextValue {
id: string;
value: string | null;
shape: AvatarUploadShape;
size: number;
disabled?: boolean;
dragging: boolean;
getInputProps: DropzoneState['getInputProps'];
error: AvatarUploadErrorInfo | null;
pending: string | null;
registerCropper: (ref: ImageCropperRef | null) => void;
openPicker: () => void;
confirmCrop: () => void;
cancelCrop: () => void;
remove: () => void;
}
const AvatarUploadContext = React.createContext<AvatarUploadContextValue | null>(null);
const useAvatarUpload = () => {
const ctx = React.useContext(AvatarUploadContext);
if (!ctx) {
throw new Error('AvatarUpload compound parts must be used inside <AvatarUpload>');
}
return ctx;
};
export interface AvatarUploadProps extends Omit<
React.ComponentProps<'div'>,
'onDrop' | 'onDragOver' | 'onDragEnter' | 'onDragLeave' | 'defaultValue' | 'onError'
> {
id?: string;
/** Data URL of the current image. */
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
shape?: AvatarUploadShape;
/** Preview edge in px. */
size?: number;
/** Max file size in bytes. */
maxSize?: number;
/** MIME types mapped to extensions. Defaults to any image. */
accept?: Accept;
/** Edge of the exported square, in px. */
outputSize?: number;
/** Set false to skip the crop dialog and use the file as-is. */
crop?: boolean;
disabled?: boolean;
onError?: (error: AvatarUploadErrorInfo) => void;
}
const AvatarUpload = ({
id,
value: valueProp,
defaultValue = null,
onValueChange,
shape = 'circle',
size = 96,
maxSize,
accept = IMAGE_TYPES,
outputSize = 512,
crop = true,
disabled,
onError,
className,
children,
...props
}: AvatarUploadProps) => {
const reactId = React.useId();
const rootId = id ?? reactId;
const cropperRef = React.useRef<ImageCropperRef | null>(null);
const [internal, setInternal] = React.useState<string | null>(defaultValue);
const value = valueProp === undefined ? internal : valueProp;
const [pending, setPending] = React.useState<string | null>(null);
const [error, setError] = React.useState<AvatarUploadErrorInfo | null>(null);
// Only the latest read may land, and never after unmount.
const readIdRef = React.useRef(0);
React.useEffect(
() => () => {
readIdRef.current += 1;
},
[],
);
const setValue = React.useCallback(
(next: string | null) => {
if (valueProp === undefined) setInternal(next);
onValueChange?.(next);
},
[valueProp, onValueChange],
);
const fail = React.useCallback(
(err: AvatarUploadErrorInfo) => {
setError(err);
onError?.(err);
},
[onError],
);
const readFile = (file: File) => {
setError(null);
const readId = ++readIdRef.current;
readAsDataUrl(file)
.then((url) => {
if (readId !== readIdRef.current) return;
if (crop) setPending(url);
else setValue(url);
})
.catch(() => {
if (readId !== readIdRef.current) return;
fail({ reason: 'read', message: 'Could not read that file.' });
});
};
const { getRootProps, getInputProps, open, isDragActive } = useDropzone({
accept,
maxSize,
multiple: false,
disabled,
noClick: true,
noKeyboard: true,
onDropAccepted: ([file]) => readFile(file),
onDropRejected: ([rejection]) =>
rejection.errors[0]?.code === 'file-too-large'
? fail({ reason: 'size', message: `Image must be under ${formatBytes(maxSize ?? 0)}.` })
: fail({ reason: 'type', message: 'That file type is not supported.' }),
});
const registerCropper = React.useCallback((ref: ImageCropperRef | null) => {
cropperRef.current = ref;
}, []);
const confirmCrop = React.useCallback(() => {
const url = cropperRef.current?.getCroppedDataUrl({ size: outputSize });
setValue(url ?? pending);
setPending(null);
}, [outputSize, pending, setValue]);
const cancelCrop = React.useCallback(() => setPending(null), []);
const remove = React.useCallback(() => {
if (disabled) return;
setError(null);
setValue(null);
}, [disabled, setValue]);
const ctx = React.useMemo<AvatarUploadContextValue>(
() => ({
id: rootId,
value,
shape,
size,
disabled,
dragging: isDragActive,
getInputProps,
error,
pending,
registerCropper,
openPicker: open,
confirmCrop,
cancelCrop,
remove,
}),
[
rootId,
value,
shape,
size,
disabled,
isDragActive,
getInputProps,
error,
pending,
registerCropper,
open,
confirmCrop,
cancelCrop,
remove,
],
);
return (
<AvatarUploadContext.Provider value={ctx}>
<div
{...getRootProps({ className: cn('inline-grid w-fit justify-items-center gap-2', className), ...props })}
data-slot="avatar-upload"
data-shape={shape}
data-dragging={isDragActive || undefined}
data-disabled={disabled || undefined}
>
{children}
</div>
</AvatarUploadContext.Provider>
);
};
interface AvatarUploadPreviewProps extends React.ComponentProps<'div'> {
/** Initials or a short label shown when there is no image. */
fallback?: React.ReactNode;
alt?: string;
}
const AvatarUploadPreview = ({
fallback,
alt = 'Avatar',
className,
children,
style,
...props
}: AvatarUploadPreviewProps) => {
const ctx = useAvatarUpload();
return (
<div
data-slot="avatar-upload-preview"
data-empty={ctx.value ? undefined : ''}
className={cn('group/avatar relative shrink-0', className)}
style={{ width: ctx.size, height: ctx.size, ...style }}
{...props}
>
<div
data-slot="avatar-upload-frame"
className={cn(
'flex size-full items-center justify-center overflow-hidden border border-border bg-muted text-muted-foreground transition-colors motion-reduce:transition-none',
ctx.shape === 'circle' ? 'rounded-full' : 'rounded-md',
ctx.dragging && 'border-foreground/40 bg-accent',
)}
>
{ctx.value ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={ctx.value}
alt={alt}
draggable={false}
data-slot="avatar-upload-image"
className="size-full object-cover select-none"
/>
) : (
<span
aria-hidden
data-slot="avatar-upload-fallback"
className="font-medium tracking-[0.04em] uppercase"
style={{ fontSize: Math.max(12, Math.round(ctx.size / 3)) }}
>
{fallback ?? <Camera className="size-[35%]" />}
</span>
)}
</div>
{children}
</div>
);
};
interface AvatarUploadTriggerProps extends Omit<React.ComponentProps<'button'>, 'type'> {
/** `overlay` sits on top of the preview; `button` is a plain control. */
variant?: 'overlay' | 'button';
}
const AvatarUploadTrigger = ({
variant = 'overlay',
className,
children,
onClick,
...props
}: AvatarUploadTriggerProps) => {
const ctx = useAvatarUpload();
const label = ctx.value ? 'Change photo' : 'Upload photo';
if (variant === 'button') {
return (
<Button
type="button"
variant="outline"
size="sm"
disabled={ctx.disabled}
data-slot="avatar-upload-trigger"
className={className}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) ctx.openPicker();
}}
{...props}
>
{children ?? (
<>
<Camera />
{label}
</>
)}
</Button>
);
}
return (
<button
type="button"
aria-label={typeof children === 'string' ? children : label}
disabled={ctx.disabled}
data-slot="avatar-upload-trigger"
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) ctx.openPicker();
}}
className={cn(
'absolute inset-0 flex items-center justify-center bg-background/70 text-foreground opacity-0 backdrop-blur-[2px] transition-opacity outline-none',
'hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
'group-data-[empty]/avatar:bg-transparent group-data-[empty]/avatar:opacity-100 group-data-[empty]/avatar:backdrop-blur-none',
'disabled:pointer-events-none motion-reduce:transition-none',
ctx.shape === 'circle' ? 'rounded-full' : 'rounded-md',
className,
)}
{...props}
>
{children ?? (ctx.value ? <Camera className="size-5" /> : <span className="sr-only">{label}</span>)}
</button>
);
};
type AvatarUploadInputProps = Omit<React.ComponentProps<'input'>, 'type' | 'accept' | 'onChange' | 'multiple' | 'ref'>;
const AvatarUploadInput = (props: AvatarUploadInputProps) => {
const ctx = useAvatarUpload();
return <input {...ctx.getInputProps({ id: `${ctx.id}-input`, ...props })} data-slot="avatar-upload-input" />;
};
type AvatarUploadRemoveProps = Omit<React.ComponentProps<'button'>, 'type'>;
const AvatarUploadRemove = ({ className, children, onClick, ...props }: AvatarUploadRemoveProps) => {
const ctx = useAvatarUpload();
if (!ctx.value) return null;
return (
<Button
type="button"
variant="outline"
size="icon-xs"
aria-label="Remove photo"
disabled={ctx.disabled}
data-slot="avatar-upload-remove"
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) ctx.remove();
}}
className={cn('absolute -end-1 -top-1 z-10 rounded-full', className)}
{...props}
>
{children ?? <X className="size-3" />}
</Button>
);
};
interface AvatarUploadCropDialogProps extends Omit<React.ComponentProps<typeof DialogContent>, 'children'> {
title?: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
zoomLabel?: string;
maxZoom?: number;
}
const AvatarUploadCropDialog = ({
title = 'Adjust your photo',
description = 'Drag to reposition. Scroll or use the slider to zoom.',
confirmLabel = 'Apply',
cancelLabel = 'Cancel',
zoomLabel = 'Zoom',
maxZoom = 3,
className,
...props
}: AvatarUploadCropDialogProps) => {
const ctx = useAvatarUpload();
// Destructured: `ctx.registerCropper` at a ref site makes the compiler read every ctx.* as a ref.
const { registerCropper } = ctx;
return (
<Dialog
open={ctx.pending !== null}
onOpenChange={(open) => {
if (!open) ctx.cancelCrop();
}}
>
<DialogContent data-slot="avatar-upload-crop-dialog" className={cn('sm:max-w-sm', className)} {...props}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{ctx.pending && (
<ImageCropper
ref={registerCropper}
src={ctx.pending}
alt={title}
aspect={1}
shape={ctx.shape === 'circle' ? 'round' : 'rect'}
maxZoom={maxZoom}
data-slot="avatar-upload-cropper"
>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground uppercase">{zoomLabel}</span>
<ImageCropperZoom aria-label={zoomLabel} />
</div>
</ImageCropper>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={ctx.cancelCrop}>
{cancelLabel}
</Button>
<Button type="button" onClick={ctx.confirmCrop}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
interface AvatarUploadErrorProps extends Omit<React.ComponentProps<'p'>, 'children'> {
/** Override the message per reason. */
messages?: Partial<Record<AvatarUploadErrorInfo['reason'], string>>;
}
const AvatarUploadErrorMessage = ({ messages, className, ...props }: AvatarUploadErrorProps) => {
const ctx = useAvatarUpload();
if (!ctx.error) return null;
return (
<p
role="alert"
data-slot="avatar-upload-error"
data-reason={ctx.error.reason}
className={cn('text-center text-sm text-destructive', className)}
{...props}
>
{messages?.[ctx.error.reason] ?? ctx.error.message}
</p>
);
};
export {
AvatarUpload,
AvatarUploadPreview,
AvatarUploadTrigger,
AvatarUploadInput,
AvatarUploadRemove,
AvatarUploadCropDialog,
AvatarUploadErrorMessage as AvatarUploadError,
};
Dependencies
shadcn registry
npm
Shipped