Skip to content
UI Kit
Accent color

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

4 variants

Variants

Frame a photo

A round window over the photo, drag to place it, zoom with the slider, the wheel or + and -, arrows nudge it. Thirds appear while you frame, small previews follow, the framing goes to the server.

Open in Studio

Initials

Initials from the name on a color that keeps white text readable, round or square, so no photo is needed. Three sizes show it as it will appear.

Open in Studio

Settings row

The current picture with name and email. Its menu uploads a photo, switches to initials or removes the picture.

Open in Studio

Details

What Avatar picker brings along, read from the registry and the source.

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

Props

The AvatarPickerProps interface from components/ui/avatar-picker.tsx, with the comments from the code.

  • variantoptional
    AvatarPickerVariant

    cropinitialsgalleryinline

  • nameoptional
    string

    The person, for initials and labels

  • descriptionoptional
    string

    A second line in the inline look, e.g. the email

  • imageoptional
    React.ReactNode

    crop, inline: the current picture as a node (img or drawing)

  • optionsoptional
    React.ReactNode[]

    gallery: pictures to choose from

  • defaultSelectedoptional
    number

    gallery: index chosen at the start

  • colorsoptional
    string[]

    initials: colours to choose from

  • defaultColoroptional
    string
  • defaultShapeoptional
    'circle' | 'rounded'
  • onFramingChangeoptional
    (f: Framing) => void
  • onFileChangeoptional
    (file: File | null) => void
  • onSelectoptional
    (index: number) => void
  • onSaveoptional
    () => 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
photoEnglish Profile photoGerman Profilfoto
dropEnglish Drop a photo here orGerman Foto hierher ziehen oder
chooseEnglish choose a fileGerman Datei wählen
hintEnglish JPG, PNG or WebP, at least 400 px.German JPG, PNG oder WebP, mindestens 400 px.
zoomEnglish ZoomGerman Zoom
zoomInEnglish Zoom inGerman Vergrößern
zoomOutEnglish Zoom outGerman Verkleinern
frameEnglish Frame of the photoGerman Ausschnitt des Fotos
frameHelpEnglish 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.
removeEnglish Remove photoGerman Foto entfernen
replaceEnglish ReplaceGerman Ersetzen
saveEnglish Save photoGerman Foto sichern
initialsEnglish InitialsGerman Initialen
colorEnglish ColorGerman Farbe
shapeEnglish ShapeGerman Form
circleEnglish RoundGerman Rund
roundedEnglish SquareGerman Eckig
galleryEnglish Choose a pictureGerman Bild auswählen
pictureEnglish PictureGerman Bild
changeEnglish ChangeGerman Ändern
uploadEnglish Upload a photoGerman Foto hochladen
useInitialsEnglish Use initialsGerman Initialen nutzen
previewEnglish PreviewGerman Vorschau
nameEnglish NameGerman 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
components/ui/avatar-picker.tsx
'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.

Often searched

Jump to

Copied to the clipboard