Zum Inhalt springen
UI Kit
Accent

Profilbild-Auswahl

Das Bild für ein Profil. Ein Foto per Ziehen, Zoom und Tasten zuschneiden, ein Bild aus Initialen machen, aus einer Auswahl wählen oder es in einer kompakten Einstellungszeile ändern.

Eine von 6 Components in Spezielle Eingabefelder

4 Variants

Variants

Foto zuschneiden

Ein rundes Fenster über dem Foto, ziehen zum Platzieren, Zoom mit Regler, Rad oder + und -, Pfeile schieben fein. Drittel erscheinen beim Zuschneiden, kleine Vorschauen folgen, der Ausschnitt geht an den Server.

Im Studio öffnen

Initialen

Initialen aus dem Namen auf einer Farbe, auf der weiße Schrift lesbar bleibt, rund oder eckig, ganz ohne Foto. Drei Größen zeigen es so, wie es erscheint.

Im Studio öffnen

Einstellungszeile

Das aktuelle Bild mit Name und E-Mail. Sein Menü lädt ein Foto hoch, wechselt zu Initialen oder entfernt das Bild.

Im Studio öffnen

Details

Was Profilbild-Auswahl mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/avatar-picker.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 24 eigene Texte

Props

Die Schnittstelle AvatarPickerProps aus components/ui/avatar-picker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Eingebaute Texte

Diese Texte schreibt Profilbild-Auswahl selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 24 Texte anzeigen
photoEnglisch Profile photoDeutsch Profilfoto
dropEnglisch Drop a photo here orDeutsch Foto hierher ziehen oder
chooseEnglisch choose a fileDeutsch Datei wählen
hintEnglisch JPG, PNG or WebP, at least 400 px.Deutsch JPG, PNG oder WebP, mindestens 400 px.
zoomEnglisch ZoomDeutsch Zoom
zoomInEnglisch Zoom inDeutsch Vergrößern
zoomOutEnglisch Zoom outDeutsch Verkleinern
frameEnglisch Frame of the photoDeutsch Ausschnitt des Fotos
frameHelpEnglisch Drag or use the arrow keys to move, the plus and minus keys to zoom.Deutsch Ziehen oder Pfeiltasten zum Verschieben, Plus- und Minustaste zum Zoomen.
removeEnglisch Remove photoDeutsch Foto entfernen
replaceEnglisch ReplaceDeutsch Ersetzen
saveEnglisch Save photoDeutsch Foto sichern
initialsEnglisch InitialsDeutsch Initialen
colorEnglisch ColorDeutsch Farbe
shapeEnglisch ShapeDeutsch Form
circleEnglisch RoundDeutsch Rund
roundedEnglisch SquareDeutsch Eckig
galleryEnglisch Choose a pictureDeutsch Bild auswählen
pictureEnglisch PictureDeutsch Bild
changeEnglisch ChangeDeutsch Ändern
uploadEnglisch Upload a photoDeutsch Foto hochladen
useInitialsEnglisch Use initialsDeutsch Initialen nutzen
previewEnglisch PreviewDeutsch Vorschau
nameEnglisch NameDeutsch Name

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Profilbild-Auswahl reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-describedby, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, radio und radiogroup.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 428 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Profilbild-Auswahl?

Mit aiw add @ai-website/avatar-picker. Der Befehl legt die Datei components/ui/avatar-picker.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, avatar, dropdown-menu und slider. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Profilbild-Auswahl?

4 Varianten, Foto zuschneiden, Initialen, Aus einer Auswahl und Einstellungszeile. Du wählst sie mit der Prop variant, zum Beispiel variant="gallery".

Kann ich die Texte von Profilbild-Auswahl übersetzen?

Ja. Profilbild-Auswahl bringt 24 eigene Texte auf Deutsch und Englisch mit, etwa „Profilfoto“, „Foto hierher ziehen oder“ und „Datei wählen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Profilbild-Auswahl einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Profilbild-Auswahl mit aiw_components und installieren sie mit aiw_add @ai-website/avatar-picker. Nenn im Prompt die Variante, etwa variant="initials", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Profilbild-Auswahl ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden AvatarPicker-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert