Avatar Upload
A round avatar you click or drop an image on, then zoom and drag to frame it before saving.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/avatar-upload.json'use client'
import { useEffect, useRef, useState } from 'react'
import './avatar-upload.css'
export interface AvatarFrame { zoom: number; x: number; y: number }
export interface AvatarUploadProps {
initials: string
onChange?: (file: File | null, frame: AvatarFrame) => void
size?: number
fill?: 'dark' | 'light'
className?: string
}
/** Pick, frame and save a round avatar. */
export function AvatarUpload({ initials, onChange, size = 112, fill = 'dark', className }: AvatarUploadProps) {
const [file, setFile] = useState<File | null>(null)
const [url, setUrl] = useState<string>()
const [frame, setFrame] = useState<AvatarFrame>({ zoom: 1, x: 0, y: 0 })
const [editing, setEditing] = useState(false)
const [over, setOver] = useState(false)
const input = useRef<HTMLInputElement>(null)
const drag = useRef<{ px: number; py: number; x: number; y: number } | null>(null)
useEffect(() => () => { if (url) URL.revokeObjectURL(url) }, [url])
const take = (f?: File) => {
if (!f || !f.type.startsWith('image/')) return
setFile(f); setUrl(URL.createObjectURL(f)); setFrame({ zoom: 1, x: 0, y: 0 }); setEditing(true)
}
const nudge = (dx: number, dy: number) => setFrame((f) => ({ ...f, x: f.x + dx, y: f.y + dy }))
return (
<div className={['sd-avu', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-avu__ring" style={{ width: size, height: size }}>
<button
type="button"
className="sd-avu__face"
data-over={over || undefined}
aria-label={url ? (editing ? 'Drag to reposition photo' : 'Change photo') : 'Upload photo'}
onClick={() => !editing && input.current?.click()}
onDragOver={(e) => { e.preventDefault(); setOver(true) }}
onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files[0]) }}
onPointerDown={(e) => { if (!editing) return; e.currentTarget.setPointerCapture(e.pointerId); drag.current = { px: e.clientX, py: e.clientY, x: frame.x, y: frame.y } }}
onPointerMove={(e) => { if (drag.current) { const d = drag.current; setFrame((f) => ({ ...f, x: d.x + e.clientX - d.px, y: d.y + e.clientY - d.py })) } }}
onPointerUp={() => { drag.current = null }}
onKeyDown={(e) => {
if (!editing) return
const m: Record<string, [number, number]> = { ArrowLeft: [-4, 0], ArrowRight: [4, 0], ArrowUp: [0, -4], ArrowDown: [0, 4] }
if (e.key in m) { e.preventDefault(); nudge(...m[e.key]) }
}}
>
{url ? <img src={url} alt="" style={{ width: size, transform: `translate(calc(-50% + ${frame.x}px), calc(-50% + ${frame.y}px)) scale(${frame.zoom})` }} /> : initials}
</button>
{!editing && <span className="sd-avu__badge" aria-hidden><svg viewBox="0 0 16 16"><path d="M2.5 5.5h2l1-1.5h5l1 1.5h2v7h-11z" /><circle cx="8" cy="8.5" r="2.2" /></svg></span>}
</div>
<input ref={input} type="file" accept="image/*" hidden onChange={(e) => take(e.target.files?.[0])} />
{editing ? (
<>
<label className="sd-avu__zoom">Zoom<input type="range" min={1} max={3} step={0.01} value={frame.zoom} onChange={(e) => setFrame((f) => ({ ...f, zoom: Number(e.target.value) }))} /></label>
<div className="sd-avu__acts">
<button type="button" className="sd-avu__btn" onClick={() => { setEditing(false); if (!file) setUrl(undefined) }}>Cancel</button>
<button type="button" className="sd-avu__btn sd-avu__btn--p" onClick={() => { setEditing(false); onChange?.(file, frame) }}>Save</button>
</div>
</>
) : url ? (
<div className="sd-avu__acts">
<button type="button" className="sd-avu__btn" onClick={() => input.current?.click()}>Replace</button>
<button type="button" className="sd-avu__btn" onClick={() => { setUrl(undefined); setFile(null); onChange?.(null, frame) }}>Remove</button>
</div>
) : (
<p className="sd-avu__hint">Click or drop an image<br />PNG or JPG, up to 5 MB</p>
)}
</div>
)
}.sd-avu { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-avu[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-avu button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-avu :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-avu .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-avu { width: min(100%, 300px); display: grid; justify-items: center; gap: 14px; padding: 20px 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-avu__ring { position: relative; border-radius: 999px; }
.sd-avu__face { position: relative; display: grid; place-items: center; width: 100%; height: 100%; border-radius: 999px; overflow: hidden; background: var(--soft) !important; box-shadow: inset 0 0 0 1px var(--line); font-weight: 600; font-size: 30px; letter-spacing: 0.02em; transition: box-shadow 200ms, transform 200ms; touch-action: none; }
.sd-avu__face[data-over] { box-shadow: inset 0 0 0 2px var(--fg); transform: scale(1.04); }
.sd-avu__face img { position: absolute; left: 50%; top: 50%; max-width: none; pointer-events: none; user-select: none; }
.sd-avu__badge { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 99px; background: var(--strong); color: var(--on); box-shadow: 0 0 0 3px var(--bg); pointer-events: none; }
.sd-avu__badge svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linejoin: round; }
.sd-avu__zoom { width: 100%; display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--mute); animation: sd-avu-in 260ms ease; }
.sd-avu__zoom input { flex: 1; accent-color: currentColor; color: var(--fg); }
@keyframes sd-avu-in { from { opacity: 0; transform: translateY(4px); } }
.sd-avu__acts { display: flex; gap: 8px; }
.sd-avu__btn { height: 34px; padding: 0 14px; border-radius: 10px; font-size: 13px; font-weight: 500; box-shadow: inset 0 0 0 1px var(--line); }
.sd-avu__btn--p { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-avu__hint { font-size: 12px; color: var(--mute); text-align: center; }
@media (prefers-reduced-motion: reduce) { .sd-avu *, .sd-avu *::before, .sd-avu *::after { animation: none !important; transition: none !important; } }Overview
Empty, it shows initials and a camera badge. Choose or drop an image and it loads into the circle; a zoom slider and drag let you frame the face, and Save locks it in.
Remove returns to initials. Everything happens locally with an object URL; upload the result from onChange.
When to use
- Profile and workspace settings, onboarding.
When not to use
- Full image editing: use a dedicated cropper.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/avatar-upload.json$
npx shadcn@latest add https://sundaeui.dev/r/avatar-upload.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/avatar-upload.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/avatar-upload.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy avatar-upload.tsx and avatar-upload.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { AvatarUpload } from '@/components/sundae/avatar-upload'
<AvatarUpload />Props
| Prop | Type | Default |
|---|---|---|
initials*Shown when empty. | string | — |
onChangeCalled on save or remove. | (file: File | null, frame: { zoom: number; x: number; y: number }) => void | — |
sizeDiameter in px. | number | 112 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The avatar is a button that opens the file picker; drag-and-drop is an enhancement.
- Zoom is a labelled range input; arrow keys on the image nudge the frame.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-avu; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter / Space
- Choose image
- Arrows (on image)
- Nudge framing
FAQ
- Is Avatar Upload free?
- Yes. Avatar Upload is free for personal and commercial projects, with full source on this page.
- How do I install Avatar Upload?
- Run npx shadcn@latest add https://sundaeui.dev/r/avatar-upload.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Avatar Upload support keyboard and screen readers?
- The avatar is a button that opens the file picker; drag-and-drop is an enhancement. Zoom is a labelled range input; arrow keys on the image nudge the frame.
- Does Avatar Upload respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Drop Zone
Forms · Refined Upload Queue
Forms · RefinedStatus Picker
Forms · Refined- Pro
Photo Uploader
Forms · Refined Profile Visibility
Forms · RefinedOTP Field
Forms · Refined