Avatar picker
The picture for a profile. Frame a photo with drag, zoom and keys, make an avatar from initials, choose from a set, or change it from a compact settings row.
One of 6 components in Special input fields
Variants
Details
What Avatar picker brings along, read from the registry and the source.
- Category
- Special input fields
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/avatar-picker.tsx
- Brings along
- utilskiticonavatardropdown-menuslider
- npm packages
- None
- Languages
- English and German, 24 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AvatarPickerProps interface from components/ui/avatar-picker.tsx, with the comments from the code.
variantoptionalAvatarPickerVariantcropinitialsgalleryinline
nameoptionalstringThe person, for initials and labels
descriptionoptionalstringA second line in the inline look, e.g. the email
imageoptionalReact.ReactNodecrop, inline: the current picture as a node (img or drawing)
optionsoptionalReact.ReactNode[]gallery: pictures to choose from
defaultSelectedoptionalnumbergallery: index chosen at the start
colorsoptionalstring[]initials: colours to choose from
defaultColoroptionalstringdefaultShapeoptional'circle' | 'rounded'onFramingChangeoptional(f: Framing) => voidonFileChangeoptional(file: File | null) => voidonSelectoptional(index: number) => voidonSaveoptional() => void
Built-in texts
Avatar picker writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 24 texts
| Key | English | German |
|---|---|---|
| photo | English Profile photo | German Profilfoto |
| drop | English Drop a photo here or | German Foto hierher ziehen oder |
| choose | English choose a file | German Datei wählen |
| hint | English JPG, PNG or WebP, at least 400 px. | German JPG, PNG oder WebP, mindestens 400 px. |
| zoom | English Zoom | German Zoom |
| zoomIn | English Zoom in | German Vergrößern |
| zoomOut | English Zoom out | German Verkleinern |
| frame | English Frame of the photo | German Ausschnitt des Fotos |
| frameHelp | English Drag or use the arrow keys to move, the plus and minus keys to zoom. | German Ziehen oder Pfeiltasten zum Verschieben, Plus- und Minustaste zum Zoomen. |
| remove | English Remove photo | German Foto entfernen |
| replace | English Replace | German Ersetzen |
| save | English Save photo | German Foto sichern |
| initials | English Initials | German Initialen |
| color | English Color | German Farbe |
| shape | English Shape | German Form |
| circle | English Round | German Rund |
| rounded | English Square | German Eckig |
| gallery | English Choose a picture | German Bild auswählen |
| picture | English Picture | German Bild |
| change | English Change | German Ändern |
| upload | English Upload a photo | German Foto hochladen |
| useInitials | English Use initials | German Initialen nutzen |
| preview | English Preview | German Vorschau |
| name | English Name | German Name |
Accessibility
Read from the source of the component.
- Avatar picker handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-describedby, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Uses the roles group, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 428 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Avatar, initials as toInitials } from '@/components/ui/avatar';
import { DropdownMenu } from '@/components/ui/dropdown-menu';
import { Slider } from '@/components/ui/slider';
/*
* Choose the picture for a profile: upload and frame a photo, make an avatar
* from initials and a colour, pick one from a set, or change it from a
* compact row in the settings. Framing works on any picture: drag it, zoom
* with the slider, the wheel or + and -, move it with the arrow keys. The
* result is the framing (zoom and offset), so the server crops the original.
*/
const TEXTS = {
en: {
photo: 'Profile photo', drop: 'Drop a photo here or', choose: 'choose a file', hint: 'JPG, PNG or WebP, at least 400 px.', zoom: 'Zoom', zoomIn: 'Zoom in', zoomOut: 'Zoom out', frame: 'Frame of the photo', frameHelp: 'Drag or use the arrow keys to move, the plus and minus keys to zoom.',
remove: 'Remove photo', replace: 'Replace', save: 'Save photo', initials: 'Initials', color: 'Color', shape: 'Shape', circle: 'Round', rounded: 'Square', gallery: 'Choose a picture', picture: 'Picture',
change: 'Change', upload: 'Upload a photo', useInitials: 'Use initials', preview: 'Preview', name: 'Name',
},
de: {
photo: 'Profilfoto', drop: 'Foto hierher ziehen oder', choose: 'Datei wählen', hint: 'JPG, PNG oder WebP, mindestens 400 px.', zoom: 'Zoom', zoomIn: 'Vergrößern', zoomOut: 'Verkleinern', frame: 'Ausschnitt des Fotos', frameHelp: 'Ziehen oder Pfeiltasten zum Verschieben, Plus- und Minustaste zum Zoomen.',
remove: 'Foto entfernen', replace: 'Ersetzen', save: 'Foto sichern', initials: 'Initialen', color: 'Farbe', shape: 'Form', circle: 'Rund', rounded: 'Eckig', gallery: 'Bild auswählen', picture: 'Bild',
change: 'Ändern', upload: 'Foto hochladen', useInitials: 'Initialen nutzen', preview: 'Vorschau', name: 'Name',
},
};
type Texts = (typeof TEXTS)['en'];
export interface Framing {
zoom: number;
/** Offset of the centre, -1 to 1 of the free room */
x: number;
y: number;
}
export type AvatarPickerVariant = 'crop' | 'initials' | 'gallery' | 'inline';
export interface AvatarPickerProps {
variant?: AvatarPickerVariant;
/** The person, for initials and labels */
name?: string;
/** A second line in the inline look, e.g. the email */
description?: string;
/** crop, inline: the current picture as a node (img or drawing) */
image?: React.ReactNode;
/** gallery: pictures to choose from */
options?: React.ReactNode[];
/** gallery: index chosen at the start */
defaultSelected?: number;
/** initials: colours to choose from */
colors?: string[];
defaultColor?: string;
defaultShape?: 'circle' | 'rounded';
onFramingChange?: (f: Framing) => void;
onFileChange?: (file: File | null) => void;
onSelect?: (index: number) => void;
onSave?: () => void;
labels?: Partial<Texts>;
className?: string;
}
/** Each keeps white initials above 4.5:1 */
const DEFAULT_COLORS = ['#1A63C4', '#0B7A55', '#B4532A', '#7A4BC2', '#8A6A12', '#B0335C', '#24324A', '#5F6B7A'];
const MIN_ZOOM = 1;
const MAX_ZOOM = 3;
/** The framing area: a square with a round window, the picture moves and scales under it */
function Cropper({ picture, t, onChange, name }: { picture: React.ReactNode; t: Texts; onChange?: (f: Framing) => void; name?: string }) {
const locale = useLocale();
const pct = (v: number) => `${Math.round(v * 100)}${locale === 'de' ? ' %' : '%'}`;
const [f, setF] = useState<Framing>({ zoom: 1.2, x: 0, y: 0 });
const drag = useRef<{ x: number; y: number; fx: number; fy: number } | null>(null);
const box = useRef<HTMLDivElement>(null);
const helpId = useId();
const set = (next: Framing) => {
const z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next.zoom));
const v = { zoom: round(z, 3), x: round(Math.max(-1, Math.min(1, next.x)), 3), y: round(Math.max(-1, Math.min(1, next.y)), 3) };
setF(v);
onChange?.(v);
};
// Free room on each side in percent of the box: the picture always covers the window
const room = ((f.zoom - 1) / 2) * 100;
return (
<div className="grid gap-4">
<div
ref={box}
role="group"
tabIndex={0}
aria-label={t.frame}
aria-describedby={helpId}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, y: e.clientY, fx: f.x, fy: f.y };
}}
onPointerMove={(e) => {
const d = drag.current;
if (!d || !box.current) return;
const size = box.current.clientWidth;
const free = ((f.zoom - 1) / 2) * size || 1;
set({ ...f, x: d.fx + (e.clientX - d.x) / free, y: d.fy + (e.clientY - d.y) / free });
}}
onPointerUp={() => (drag.current = null)}
onPointerCancel={() => (drag.current = null)}
onWheel={(e) => {
if (!e.ctrlKey && !e.metaKey && Math.abs(e.deltaY) < 1) return;
set({ ...f, zoom: f.zoom - e.deltaY * 0.0015 });
}}
onKeyDown={(e) => {
const step = e.shiftKey ? 0.25 : 0.06;
const map: Record<string, () => void> = {
ArrowLeft: () => set({ ...f, x: f.x + step }),
ArrowRight: () => set({ ...f, x: f.x - step }),
ArrowUp: () => set({ ...f, y: f.y + step }),
ArrowDown: () => set({ ...f, y: f.y - step }),
'+': () => set({ ...f, zoom: f.zoom + 0.1 }),
'=': () => set({ ...f, zoom: f.zoom + 0.1 }),
'-': () => set({ ...f, zoom: f.zoom - 0.1 }),
'0': () => set({ zoom: 1.2, x: 0, y: 0 }),
};
if (!map[e.key]) return;
e.preventDefault();
map[e.key]();
}}
className="group/crop relative mx-auto aspect-square w-full max-w-[17rem] cursor-grab touch-none select-none overflow-hidden rounded-xl bg-muted outline-none active:cursor-grabbing focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_60%,transparent)]"
data-clip-ok
>
<div
className="absolute inset-0 transition-transform duration-75 [&>*]:size-full [&_img]:object-cover"
style={{ transform: `translate(${round(f.x * room, 3)}%, ${round(f.y * room, 3)}%) scale(${f.zoom})` }}
>
{picture}
</div>
{/* The round window: everything outside is dimmed, a third grid helps with the face */}
<div aria-hidden className="pointer-events-none absolute inset-0 bg-black/55 [mask-image:radial-gradient(circle_closest-side,transparent_99.4%,#000_100%)]" />
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgb(0_0_0/0.15)]" />
<div aria-hidden className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-active/crop:opacity-100 group-focus-visible/crop:opacity-100 [background:linear-gradient(to_right,transparent_33.1%,rgb(255_255_255/0.5)_33.3%,transparent_33.6%,transparent_66.4%,rgb(255_255_255/0.5)_66.6%,transparent_66.9%),linear-gradient(to_bottom,transparent_33.1%,rgb(255_255_255/0.5)_33.3%,transparent_33.6%,transparent_66.4%,rgb(255_255_255/0.5)_66.6%,transparent_66.9%)]" />
</div>
<p id={helpId} className="sr-only">{t.frameHelp}</p>
<div className="mx-auto flex w-full max-w-[17rem] items-end gap-3">
<button type="button" aria-label={t.zoomOut} onClick={() => set({ ...f, zoom: f.zoom - 0.2 })} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:mb-1.5 sm:size-8">
<Icon name="zoom-out" size={16} />
</button>
<Slider label={t.zoom} min={MIN_ZOOM} max={MAX_ZOOM} step={0.01} value={f.zoom} onValueChange={(v) => set({ ...f, zoom: v })} format={pct} className="min-w-0 flex-1" />
<button type="button" aria-label={t.zoomIn} onClick={() => set({ ...f, zoom: f.zoom + 0.2 })} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:mb-1.5 sm:size-8">
<Icon name="zoom-in" size={16} />
</button>
</div>
<span aria-live="polite" className="sr-only">{`${t.zoom} ${pct(f.zoom)}`}</span>
<PreviewRow picture={picture} f={f} name={name} t={t} />
</div>
);
}
/** The framed picture at the sizes it will appear in */
function PreviewRow({ picture, f, name, t }: { picture: React.ReactNode; f: Framing; name?: string; t: Texts }) {
const room = ((f.zoom - 1) / 2) * 100;
return (
<div className="flex items-center justify-center gap-4 border-t border-border pt-4">
{name && (
<span className="me-auto min-w-0">
<span className="block text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">{t.preview}</span>
<span className="block truncate text-sm font-medium text-foreground">{name}</span>
</span>
)}
{[48, 32, 24].map((s) => (
<span key={s} aria-hidden className="relative shrink-0 overflow-hidden rounded-full ring-1 ring-border" style={{ width: s, height: s }} data-clip-ok>
<span className="absolute inset-0 [&>*]:size-full [&_img]:object-cover" style={{ transform: `translate(${round(f.x * room, 3)}%, ${round(f.y * room, 3)}%) scale(${f.zoom})` }}>
{picture}
</span>
</span>
))}
</div>
);
}
/**
* Pick a profile picture: frame a photo, make initials, choose from a set or change it from a row.
* <AvatarPicker name="Elif Aydın" image={<img src={photo} alt="" />} onFramingChange={setFraming} />
* <AvatarPicker variant="initials" name="Jonas Weber" />
* <AvatarPicker variant="gallery" options={[…]} />
* <AvatarPicker variant="inline" name="Mara Lindqvist" description="mara@example.com" image={…} />
*/
export function AvatarPicker({ variant = 'crop', name = '', description, image, options = [], defaultSelected = 0, colors = DEFAULT_COLORS, defaultColor, defaultShape = 'circle', onFramingChange, onFileChange, onSelect, onSave, labels, className }: AvatarPickerProps) {
const t = useTexts(TEXTS, labels);
const [file, setFile] = useState<string | null>(null);
const [over, setOver] = useState(false);
const [removed, setRemoved] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [color, setColor] = useState(defaultColor ?? colors[0]);
const [shape, setShape] = useState<'circle' | 'rounded'>(defaultShape);
const [who, setWho] = useState(name);
// A new name from outside replaces what was typed
useEffect(() => setWho(name), [name]);
const [sel, setSel] = useState(defaultSelected);
const groupId = useId();
useEffect(() => () => void (file && URL.revokeObjectURL(file)), [file]);
const take = (f: File | null | undefined) => {
if (!f || !f.type.startsWith('image/')) return;
setFile(URL.createObjectURL(f));
setRemoved(false);
onFileChange?.(f);
};
const picture = file ? <img src={file} alt="" draggable={false} /> : removed ? null : image;
const fileInput = <input ref={fileRef} type="file" accept="image/*" className="sr-only" tabIndex={-1} aria-hidden onChange={(e) => take(e.target.files?.[0])} />;
/* ---------- initials ---------- */
if (variant === 'initials') {
const ini = toInitials(who || '·');
const fg = '#ffffff';
const tile = (s: number) => (
<span key={s} aria-hidden className={cn('grid shrink-0 place-items-center font-semibold tracking-[-0.02em] transition-[background-color,border-radius] duration-300', shape === 'circle' ? 'rounded-full' : 'rounded-[22%]')} style={{ width: s, height: s, background: color, color: fg, fontSize: round(s * 0.38, 1) }}>
<span key={ini} className="transition-[opacity,scale] duration-300 starting:scale-75 starting:opacity-0">{ini}</span>
</span>
);
return (
<div className={cn('grid grid-cols-[minmax(0,1fr)] gap-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center', className)}>
<div className="flex items-end justify-center gap-3 sm:flex-col sm:items-center">
{tile(112)}
<span className="grid justify-items-center gap-2">
<span className="flex items-end gap-2">{[40, 28].map(tile)}</span>
<span className="max-w-[9rem] truncate text-[12.5px] font-medium text-muted-foreground">{who}</span>
</span>
</div>
<div className="grid gap-5">
<div className="grid gap-2">
<label htmlFor={`${groupId}-name`} className="text-sm font-medium text-foreground">{t.name}</label>
<input id={`${groupId}-name`} value={who} onChange={(e) => setWho(e.target.value)} className="h-11 rounded-lg border border-input bg-background px-3.5 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-200 focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:text-sm" />
</div>
<div className="grid gap-2">
<p id={`${groupId}-c`} className="text-sm font-medium text-foreground">{t.color}</p>
<div role="radiogroup" aria-labelledby={`${groupId}-c`} className="flex flex-wrap gap-2.5">
{colors.map((c, i) => (
<button
key={c}
type="button"
role="radio"
aria-checked={c === color}
aria-label={`${t.color} ${i + 1}`}
tabIndex={c === color || (!colors.includes(color) && i === 0) ? 0 : -1}
onClick={() => setColor(c)}
onKeyDown={(e) => {
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const j = (i + d + colors.length) % colors.length;
setColor(colors[j]);
(e.currentTarget.parentElement?.children[j] as HTMLElement | undefined)?.focus();
}}
className={cn('grid size-11 place-items-center rounded-full outline-none transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-110 focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_60%,transparent)] sm:size-8', c === color && 'shadow-[0_0_0_2px_var(--background),0_0_0_4px_var(--foreground)]')}
style={{ background: c }}
>
{c === color && <Icon name="check" size={14} strokeWidth={2.4} className="text-white" />}
</button>
))}
</div>
</div>
<div className="grid gap-2">
<p id={`${groupId}-s`} className="text-sm font-medium text-foreground">{t.shape}</p>
<div
role="radiogroup"
aria-labelledby={`${groupId}-s`}
onKeyDown={(e) => {
// Radio group after the APG: arrows move and choose, one tab stop
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
e.preventDefault();
const next = shape === 'circle' ? 'rounded' : 'circle';
setShape(next);
const group = e.currentTarget;
requestAnimationFrame(() => group.querySelector<HTMLElement>(`[data-shape="${next}"]`)?.focus());
}}
className="inline-flex h-12 w-fit rounded-lg bg-muted p-0.5 sm:h-9"
>
{(['circle', 'rounded'] as const).map((s) => (
<button key={s} type="button" role="radio" data-shape={s} tabIndex={shape === s ? 0 : -1} aria-checked={shape === s} onClick={() => setShape(s)} className={cn('inline-flex items-center gap-2 rounded-[7px] px-3 text-[13px] font-medium outline-none transition-[background-color,color,box-shadow] duration-200 focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', shape === s ? 'bg-background text-foreground shadow-[var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
<span aria-hidden className={cn('size-3.5 border-[1.5px] border-current', s === 'circle' ? 'rounded-full' : 'rounded-[3px]')} />
{s === 'circle' ? t.circle : t.rounded}
</button>
))}
</div>
</div>
</div>
</div>
);
}
/* ---------- gallery ---------- */
if (variant === 'gallery') {
return (
<div className={cn('grid gap-4', className)}>
<div className="flex items-center gap-4">
<span className="relative size-16 shrink-0 overflow-hidden rounded-full ring-2 ring-[color-mix(in_oklab,var(--primary)_55%,transparent)] ring-offset-2 ring-offset-background [&>*]:size-full" data-clip-ok>
<span key={sel} className="absolute inset-0 transition-[opacity,scale] duration-300 starting:scale-90 starting:opacity-0 [&>*]:size-full">{options[sel]}</span>
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{name}</p>
<p className="text-[13px] text-muted-foreground">{t.gallery}</p>
</div>
</div>
<div role="radiogroup" aria-label={t.gallery} className="grid grid-cols-4 gap-2.5 sm:grid-cols-6">
{options.map((o, i) => (
<button
key={i}
type="button"
role="radio"
aria-checked={sel === i}
aria-label={`${t.picture} ${i + 1}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => {
setSel(i);
onSelect?.(i);
}}
onKeyDown={(e) => {
const d = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 4, ArrowUp: -4 }[e.key];
if (!d) return;
e.preventDefault();
const j = (i + d + options.length) % options.length;
setSel(j);
onSelect?.(j);
(e.currentTarget.parentElement?.children[j] as HTMLElement | undefined)?.focus();
}}
className={cn('relative aspect-square overflow-hidden rounded-full outline-none transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-105 focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_60%,transparent)]', sel === i ? 'shadow-[0_0_0_2px_var(--background),0_0_0_4px_var(--primary)]' : 'opacity-85 hover:opacity-100')}
data-clip-ok
>
<span className="absolute inset-0 [&>*]:size-full">{o}</span>
{sel === i && (
<span aria-hidden className="absolute bottom-[6%] end-[6%] grid size-[30%] min-h-4 min-w-4 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-sm)] transition-[scale] duration-300 starting:scale-0">
<Icon name="check" size={11} strokeWidth={2.6} />
</span>
)}
</button>
))}
</div>
</div>
);
}
/* ---------- inline: a settings row ---------- */
if (variant === 'inline') {
return (
<div className={cn('flex items-center gap-4', className)}>
<span className="relative size-14 shrink-0 overflow-hidden rounded-full ring-1 ring-border [&>*]:size-full [&_img]:object-cover" data-clip-ok>
{picture ?? <Avatar name={name} size="xl" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{name}</p>
{description && <p className="truncate text-[13px] text-muted-foreground">{description}</p>}
</div>
<DropdownMenu
align="end"
trigger={
<button type="button" className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-lg border border-border-strong bg-background px-3.5 text-[13px] font-medium text-foreground outline-none transition-colors hover:bg-accent focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_30%,transparent)] sm:h-9">
<Icon name="edit" size={14} />
{t.change}
</button>
}
items={[
{ label: t.upload, icon: 'upload', onSelect: () => fileRef.current?.click() },
{ label: t.useInitials, icon: 'type', onSelect: () => { setFile(null); setRemoved(true); } },
{ type: 'separator' },
{ label: t.remove, icon: 'trash', destructive: true, onSelect: () => { setFile(null); setRemoved(true); onFileChange?.(null); } },
]}
/>
{fileInput}
</div>
);
}
/* ---------- crop: upload and frame ---------- */
return (
<div className={cn('grid gap-4', className)}>
{picture ? (
<>
<Cropper key={file ?? 'start'} picture={picture} t={t} onChange={onFramingChange} name={name} />
<div className="flex flex-wrap justify-center gap-2">
<button type="button" onClick={() => fileRef.current?.click()} className="inline-flex h-11 items-center gap-1.5 rounded-lg border border-border-strong bg-background px-3.5 text-[13px] font-medium text-foreground outline-none transition-colors hover:bg-accent focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_30%,transparent)] sm:h-9">
<Icon name="upload" size={14} />
{t.replace}
</button>
<button type="button" onClick={() => { setFile(null); setRemoved(true); onFileChange?.(null); }} className="inline-flex h-11 items-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-destructive focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:h-9">
<Icon name="trash" size={14} />
{t.remove}
</button>
{onSave && (
<button type="button" onClick={onSave} className="inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-[13px] font-medium text-primary-foreground outline-none transition-[background-color] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_35%,transparent)] sm:h-9">
{t.save}
</button>
)}
</div>
</>
) : (
<div
onClick={() => fileRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
take(e.dataTransfer.files?.[0]);
}}
className={cn('mx-auto grid aspect-square w-full max-w-[17rem] cursor-pointer place-items-center rounded-xl border-2 border-dashed p-6 text-center transition-[border-color,background-color,scale] duration-200', over ? 'scale-[1.02] border-primary bg-[color-mix(in_oklab,var(--primary)_8%,transparent)]' : 'border-border-strong bg-muted/40')}
>
<div className="grid justify-items-center gap-3">
<span className="grid size-12 place-items-center rounded-full bg-background text-muted-foreground shadow-[var(--shadow-sm)]">
<Icon name="camera" size={20} />
</span>
<p className="text-[13.5px] text-muted-foreground">
{t.drop}{' '}
<button type="button" onClick={(e) => { e.stopPropagation(); fileRef.current?.click(); }} className="rounded-sm font-medium text-foreground underline underline-offset-4 outline-none focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]">
{t.choose}
</button>
</p>
<p className="text-[12px] text-subtle-foreground">{t.hint}</p>
</div>
</div>
)}
{fileInput}
</div>
);
}Frequent questions
How do I install Avatar picker?
With aiw add @ai-website/avatar-picker. The command puts components/ui/avatar-picker.tsx into your project, the code is yours from then on. It brings along icon, avatar, dropdown-menu and slider. It needs no further npm packages.
Which variants does Avatar picker have?
4 variants, Frame a photo, Initials, Choose from a set and Settings row. You pick one with the variant prop, for example variant="gallery".
Can I translate the texts of Avatar picker?
Yes. Avatar picker carries 24 texts of its own in English and German, such as “Profile photo”, “Drop a photo here or” and “choose a file”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Avatar picker?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Avatar picker with aiw_components and install it with aiw_add @ai-website/avatar-picker. Name the variant in your prompt, for example variant="initials", and the assistant writes your content straight into the props.
Can I adjust Avatar picker without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching AvatarPicker code to copy.