Zum Inhalt springen
UI Kit
Accent

Bildergalerie für KI

Erzeugte Bilder in vier Ansichten. Vier Varianten zur Auswahl mit Variieren und Vergrößern, ein Bild Schritt für Schritt verfeinern mit Fassungen zum Vergleichen, der Verlauf früherer Bilder nach Format und eine Warteschlange mit Fortschritt und Wiederholen.

Eine von 3 Components in KI-Schreiben und Erstellen

4 Variants

Variants

Details

Was Bildergalerie für KI mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
4
Version
1.0.1 · seit 1.1.0
Datei
components/ui/generation-gallery.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 38 eigene Texte

Props

Die Schnittstelle GenerationGalleryProps aus components/ui/generation-gallery.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    GenerationGalleryVariant

    variationsrefinehistoryqueue

  • titleoptional
    string
  • promptoptional
    string

    The prompt in the field

  • itemsoptional
    Generation[]

    variations: the images of one run, history: past runs, queue: jobs

  • suggestionsoptional
    { id: string; label: string; icon?: IconName }[]

    refine: suggestions to change the picked image

  • stepsoptional
    RefineStep[]

    refine: the versions so far, oldest first

  • costoptional
    string

    Credits one run costs, shown on the button

  • noteoptional
    string

    Text under the gallery, e.g. on usage rights

  • onGenerateoptional
    (prompt: string) => void
  • onActionoptional
    (action: 'upscale' | 'download' | 'use', id: string) => void

    Upscale, download and use, with the id of the image

Eingebaute Texte

Diese Texte schreibt Bildergalerie für KI selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 38 Texte anzeigen
promptEnglisch Describe the imageDeutsch Beschreib das Bild
generateEnglisch GenerateDeutsch Erzeugen
generatingEnglisch GeneratingDeutsch Wird erzeugt
selectEnglisch Select image {n}Deutsch Bild {n} wählen
selectedEnglisch SelectedDeutsch Gewählt
varyEnglisch VaryDeutsch Variieren
upscaleEnglisch UpscaleDeutsch Vergrößern
upscaledEnglisch UpscaledDeutsch Vergrößert
downloadedEnglisch DownloadedDeutsch Heruntergeladen
usedImageEnglisch Image addedDeutsch Bild übernommen
justNowEnglisch Just nowDeutsch Gerade eben
downloadEnglisch DownloadDeutsch Herunterladen
useEnglisch Use imageDeutsch Bild verwenden
aspectEnglisch FormatDeutsch Format
squareEnglisch SquareDeutsch Quadrat
portraitEnglisch PortraitDeutsch Hochformat
landscapeEnglisch LandscapeDeutsch Querformat
refineEnglisch Change somethingDeutsch Etwas ändern
refinePlaceholderEnglisch Evening light, fewer peopleDeutsch Abendlicht, weniger Menschen
applyEnglisch ApplyDeutsch Anwenden
versionsEnglisch VersionsDeutsch Fassungen
versionEnglisch Version {n}Deutsch Fassung {n}
originalEnglisch OriginalDeutsch Original
compareEnglisch Hold to compare with the previous versionDeutsch Gedrückt halten, um mit der vorigen Fassung zu vergleichen
compareShortEnglisch CompareDeutsch Vergleichen
historyEnglisch Your imagesDeutsch Deine Bilder
allEnglisch AllDeutsch Alle
reuseEnglisch Use this promptDeutsch Prompt übernehmen
copiedEnglisch Prompt in the fieldDeutsch Prompt steht im Feld
queueEnglisch RenderingDeutsch Wird erzeugt
queuedEnglisch WaitingDeutsch Wartet
doneEnglisch ReadyDeutsch Fertig
failedEnglisch FailedDeutsch Fehlgeschlagen
cancelEnglisch Cancel {name}Deutsch {name} abbrechen
retryEnglisch Try againDeutsch Neu versuchen
failedNoteEnglisch This image could not be made. Try different words.Deutsch Dieses Bild ließ sich nicht erzeugen. Versuch andere Worte.
emptyEnglisch Nothing in this format yet.Deutsch In diesem Format gibt es noch nichts.
aiMarkEnglisch Made with AIDeutsch Mit KI erstellt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Bildergalerie für KI reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-current, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, radio, radiogroup und status.
  • 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

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 714 Zeilen
components/ui/generation-gallery.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';

export type GenerationGalleryVariant = 'variations' | 'refine' | 'history' | 'queue';

export type GenerationAspect = 'square' | 'portrait' | 'landscape';

export interface Generation {
  id: string;
  prompt: string;
  /** The image, a drawn motif or your own <img> */
  media?: React.ReactNode;
  aspect?: GenerationAspect;
  /** e.g. "Studio 2" */
  model?: string;
  /** e.g. "Today, 10:42" */
  time?: string;
  /** queue: 0 to 100 while it renders */
  progress?: number;
  status?: 'done' | 'rendering' | 'queued' | 'failed';
  /** queue: why a failed job failed and what to change */
  error?: string;
}

export interface RefineStep {
  id: string;
  /** The change asked for, e.g. "Warmer light" */
  label: string;
  media?: React.ReactNode;
}

export interface GenerationGalleryProps {
  variant?: GenerationGalleryVariant;
  title?: string;
  /** The prompt in the field */
  prompt?: string;
  /** variations: the images of one run, history: past runs, queue: jobs */
  items?: Generation[];
  /** refine: suggestions to change the picked image */
  suggestions?: { id: string; label: string; icon?: IconName }[];
  /** refine: the versions so far, oldest first */
  steps?: RefineStep[];
  /** Credits one run costs, shown on the button */
  cost?: string;
  /** Text under the gallery, e.g. on usage rights */
  note?: string;
  onGenerate?: (prompt: string) => void;
  /** Upscale, download and use, with the id of the image */
  onAction?: (action: 'upscale' | 'download' | 'use', id: string) => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    prompt: 'Describe the image',
    generate: 'Generate',
    generating: 'Generating',
    
    select: 'Select image {n}',
    selected: 'Selected',
    vary: 'Vary',
    upscale: 'Upscale',
    upscaled: 'Upscaled',
    downloaded: 'Downloaded',
    usedImage: 'Image added',
    justNow: 'Just now',
    download: 'Download',
    use: 'Use image',
    aspect: 'Format',
    square: 'Square',
    portrait: 'Portrait',
    landscape: 'Landscape',
    refine: 'Change something',
    refinePlaceholder: 'Evening light, fewer people',
    apply: 'Apply',
    versions: 'Versions',
    version: 'Version {n}',
    original: 'Original',
    compare: 'Hold to compare with the previous version',
    compareShort: 'Compare',
    history: 'Your images',
    all: 'All',
    reuse: 'Use this prompt',
    copied: 'Prompt in the field',
    queue: 'Rendering',
    queued: 'Waiting',
    done: 'Ready',
    failed: 'Failed',
    cancel: 'Cancel {name}',
    retry: 'Try again',
    
    failedNote: 'This image could not be made. Try different words.',
    empty: 'Nothing in this format yet.',
    aiMark: 'Made with AI',
    
  },
  de: {
    prompt: 'Beschreib das Bild',
    generate: 'Erzeugen',
    generating: 'Wird erzeugt',
    
    select: 'Bild {n} wählen',
    selected: 'Gewählt',
    vary: 'Variieren',
    upscale: 'Vergrößern',
    upscaled: 'Vergrößert',
    downloaded: 'Heruntergeladen',
    usedImage: 'Bild übernommen',
    justNow: 'Gerade eben',
    download: 'Herunterladen',
    use: 'Bild verwenden',
    aspect: 'Format',
    square: 'Quadrat',
    portrait: 'Hochformat',
    landscape: 'Querformat',
    refine: 'Etwas ändern',
    refinePlaceholder: 'Abendlicht, weniger Menschen',
    apply: 'Anwenden',
    versions: 'Fassungen',
    version: 'Fassung {n}',
    original: 'Original',
    compare: 'Gedrückt halten, um mit der vorigen Fassung zu vergleichen',
    compareShort: 'Vergleichen',
    history: 'Deine Bilder',
    all: 'Alle',
    reuse: 'Prompt übernehmen',
    copied: 'Prompt steht im Feld',
    queue: 'Wird erzeugt',
    queued: 'Wartet',
    done: 'Fertig',
    failed: 'Fehlgeschlagen',
    cancel: '{name} abbrechen',
    retry: 'Neu versuchen',
    
    failedNote: 'Dieses Bild ließ sich nicht erzeugen. Versuch andere Worte.',
    empty: 'In diesem Format gibt es noch nichts.',
    aiMark: 'Mit KI erstellt',
    
  },
};

type Texts = (typeof TEXTS)['en'];
type P = GenerationGalleryProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value, (k, v) => (k === 'media' ? undefined : v));
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes gen-up{from{opacity:0;transform:translateY(8px)}}@keyframes gen-develop{from{filter:blur(18px) saturate(.4);transform:scale(1.06)}}@keyframes gen-sheen{from{transform:translateX(-120%)}to{transform:translateX(120%)}}@keyframes gen-noise{0%,100%{opacity:.55}50%{opacity:.85}}' +
  '@media (prefers-reduced-motion:no-preference){.gen-up{animation:gen-up .45s var(--ease-kit) var(--gen-d,0ms) both}.gen-develop{animation:gen-develop 1.1s var(--ease-kit) var(--gen-d,0ms) both}.gen-sheen{animation:gen-sheen 1.6s ease-in-out infinite}.gen-noise{animation:gen-noise 1.4s ease-in-out infinite}}';

function Style() {
  return (
    <style href="kit-generation-gallery" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const RATIO: Record<GenerationAspect, string> = { square: 'aspect-square', portrait: 'aspect-[3/4]', landscape: 'aspect-[4/3]' };

function Stage({ title, children, className }: { title?: string; children: React.ReactNode; className?: string }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className="mx-auto w-full max-w-[68rem]">
          {title && (
            <Reveal className="mb-6 sm:mb-8">
              <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

/** A rendering tile: grain, a sheen and the progress */
function Rendering({ progress, className }: { progress?: number; className?: string }) {
  return (
    <div aria-hidden className={cn('absolute inset-0 overflow-hidden bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))]', className)}>
      <div className="gen-noise absolute inset-0 bg-[radial-gradient(color-mix(in_oklab,var(--foreground)_14%,transparent)_1px,transparent_1.5px)] [background-size:7px_7px]" />
      <div className="gen-sheen absolute inset-y-0 w-2/3 bg-[linear-gradient(100deg,transparent,color-mix(in_oklab,var(--primary)_22%,transparent),transparent)]" />
      {progress !== undefined && (
        <div className="absolute inset-x-3 bottom-3 h-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--background)_60%,transparent)]">
          <div className="h-full origin-left rounded-full bg-primary transition-transform duration-300" style={{ transform: `scaleX(${round(progress / 100, 3)})` }} />
        </div>
      )}
    </div>
  );
}

/** The image in its frame, with the AI mark in the corner */
function Frame({ media, aspect = 'square', className, children, t, develop, delay = 0 }: { media?: React.ReactNode; aspect?: GenerationAspect; className?: string; children?: React.ReactNode; t: Texts; develop?: boolean; delay?: number }) {
  return (
    <div className={cn('relative isolate overflow-hidden rounded-xl bg-surface-2', RATIO[aspect], className)}>
      {/* Images fill the frame like object-fit cover, cropping is intended */}
      <div data-clip-ok className={cn('absolute inset-0 [&>*]:size-full', develop && 'gen-develop')} style={{ '--gen-d': `${delay}ms` } as React.CSSProperties}>
        {media}
      </div>
      {media ? <span className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,black_55%,transparent)] px-1.5 py-0.5 text-[10.5px] font-medium text-white backdrop-blur-sm">
        <Icon name="sparkle" size={10} />
        {t.aiMark}
      </span> : null}
      {children}
    </div>
  );
}

const btn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium transition-colors sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:opacity-60');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');

/** Prompt bar with format choice */
function PromptBar({ t, value, onValue, onSubmit, busy, cost, aspect, onAspect }: { t: Texts; value: string; onValue: (v: string) => void; onSubmit: () => void; busy: boolean; cost?: string; aspect?: GenerationAspect; onAspect?: (a: GenerationAspect) => void }) {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSubmit();
      }}
      className="rounded-2xl border border-border bg-card p-2 shadow-[var(--highlight),var(--shadow-md)] focus-within:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-within:shadow-[var(--highlight),var(--shadow-md),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]"
    >
      <label className="block">
        <span className="sr-only">{t.prompt}</span>
        <textarea
          rows={2}
          value={value}
          onChange={(e) => onValue(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault();
              onSubmit();
            }
          }}
          placeholder={t.prompt}
          className="block min-h-11 w-full resize-none bg-transparent px-2.5 py-2 text-[15px] leading-relaxed text-foreground outline-none [field-sizing:content] placeholder:text-subtle-foreground focus-visible:outline-none"
        />
      </label>
      <div className="flex flex-wrap items-center gap-2 px-1 pb-0.5 pt-1">
        {onAspect && (
          <div role="radiogroup" aria-label={t.aspect} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
            {(['square', 'portrait', 'landscape'] as const).map((a) => (
              <button key={a} type="button" role="radio" aria-checked={aspect === a} aria-label={t[a]} title={t[a]} onClick={() => onAspect(a)} className={cn('grid h-11 w-11 place-items-center rounded-md transition-colors sm:h-8 sm:w-9', aspect === a ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
                <span aria-hidden className={cn('rounded-[3px] border-[1.5px] border-current', a === 'square' ? 'size-3.5' : a === 'portrait' ? 'h-4 w-3' : 'h-3 w-4')} />
              </button>
            ))}
          </div>
        )}
        <span className="flex-1" />
        <button type="submit" disabled={busy || !value.trim()} className={primaryBtn}>
          <Icon name={busy ? 'loader' : 'sparkle'} size={15} className={busy ? 'animate-spin' : undefined} />
          {busy ? t.generating : t.generate}
          {cost && !busy && <span className="rounded-md bg-[rgb(255_255_255/0.18)] px-1.5 py-px text-[11.5px] tabular-nums">{cost}</span>}
        </button>
      </div>
    </form>
  );
}

/** Runs a fake render: progress per tile, then the next set of images develops */
function useRun(count: number) {
  const [progress, setProgress] = useState<number[] | null>(null);
  const [round_, setRound] = useState(0);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearInterval(timer.current), []);
  const start = () => {
    if (prefersReducedMotion()) return setRound((r) => r + 1);
    setProgress(Array(count).fill(0));
    let tick = 0;
    window.clearInterval(timer.current);
    timer.current = window.setInterval(() => {
      tick++;
      setProgress((p) => p && p.map((v, i) => Math.min(100, v + 3 + ((i * 7 + tick) % 5))));
      if (tick > 34) {
        window.clearInterval(timer.current);
        setProgress(null);
        setRound((r) => r + 1);
      }
    }, 70);
  };
  return { progress, round: round_, start, busy: progress !== null };
}

/* ------------------------------------------------------------------ */
/* variations                                                          */
/* ------------------------------------------------------------------ */

function Variations({ t, title, prompt = '', items = [], cost, note, onGenerate, onAction, className }: P) {
  const [text, setText] = useState(prompt);
  const [aspect, setAspect] = useState<GenerationAspect>(items[0]?.aspect ?? 'square');
  const [sel, setSel] = useState(0);
  const run = useRun(4);
  // Each run shows the images in a new order, so a new run looks like new results
  const shown = Array.from({ length: Math.min(4, items.length) }, (_, i) => items[(i + run.round) % items.length]);
  const cur = shown[sel];
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const [upscaled, setUpscaled] = useState<string[]>([]);
  const [saved, setSaved] = useState<string | null>(null);
  useEffect(() => {
    if (!saved) return;
    const id = window.setTimeout(() => setSaved(null), 1800);
    return () => window.clearTimeout(id);
  }, [saved]);
  const big = !!cur && upscaled.includes(`${cur.id}-${run.round}`);

  return (
    <Stage title={title} className={className}>
      <Reveal delay={60}>
        <PromptBar t={t} value={text} onValue={setText} busy={run.busy} cost={cost} aspect={aspect} onAspect={setAspect} onSubmit={() => (onGenerate?.(text), run.start(), setSel(0))} />
      </Reveal>
      <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_17rem] lg:items-start">
        <Stagger role="radiogroup" aria-label={t.prompt} step={90} className={cn('grid gap-2 sm:gap-3', aspect === 'landscape' ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-2')}>
          {shown.map((g, i) => {
            const on = i === sel;
            return (
              <button
                key={`${g.id}-${run.round}`}
                ref={(el) => {
                  refs.current[i] = el;
                }}
                type="button"
                role="radio"
                aria-checked={on}
                aria-label={fill(t.select, { n: i + 1 })}
                tabIndex={on ? 0 : -1}
                disabled={run.busy}
                onClick={() => setSel(i)}
                onKeyDown={(e) => {
                  const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                  if (!d) return;
                  e.preventDefault();
                  const n = (i + d + shown.length) % shown.length;
                  setSel(n);
                  refs.current[n]?.focus();
                }}
                className={cn('group relative rounded-[14px] p-[3px] text-left outline-none transition-[background-color,transform] duration-300 ease-[var(--ease-kit)] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background', on ? 'bg-primary' : 'bg-transparent hover:-translate-y-0.5 motion-reduce:hover:translate-y-0')}
              >
                <Frame media={g.media} aspect={aspect} t={t} develop={run.round > 0} delay={i * 140}>
                  {run.busy && <Rendering progress={run.progress?.[i]} />}
                  {on && !run.busy && (
                    <span className="gen-up absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-primary text-primary-foreground shadow-[var(--shadow-md)]">
                      <Icon name="check" size={13} strokeWidth={2.4} />
                    </span>
                  )}
                </Frame>
              </button>
            );
          })}
        </Stagger>
        <Reveal delay={200} className="lg:sticky lg:top-6">
          <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
            <p className="text-[12px] font-medium text-subtle-foreground">{t.selected}</p>
            <p className="mt-1 line-clamp-3 text-[13.5px] leading-relaxed text-foreground">{cur?.prompt ?? text}</p>
            <p className="mt-2 text-[12px] text-muted-foreground">{[cur?.model, t[aspect]].filter(Boolean).join(' · ')}</p>
            <div className="mt-4 grid grid-cols-2 gap-2">
              <button type="button" disabled={run.busy} onClick={() => run.start()} className={quietBtn}>
                <Icon name="shuffle" size={15} />
                {t.vary}
              </button>
              <button type="button" disabled={run.busy || big} onClick={() => cur && (setUpscaled((u) => [...u, `${cur.id}-${run.round}`]), onAction?.('upscale', cur.id))} className={cn(quietBtn, 'disabled:opacity-60')}>
                <Icon name={big ? 'check' : 'maximize'} size={15} />
                {big ? t.upscaled : t.upscale}
              </button>
              <button type="button" disabled={run.busy} onClick={() => cur && (setSaved(cur.id), onAction?.('download', cur.id))} className={cn(primaryBtn, 'col-span-2')} aria-live="polite">
                <Icon name={saved ? 'check' : 'download'} size={15} />
                {saved ? t.downloaded : t.download}
              </button>
            </div>
          </div>
          {note && <p className="mt-3 px-1 text-[12.5px] leading-relaxed text-muted-foreground">{note}</p>}
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* refine                                                              */
/* ------------------------------------------------------------------ */

function Refine({ t, title, steps: initial = [], suggestions = [], note, onAction, className }: P) {
  const [steps] = useSynced(initial);
  // Starts on the second version, so there is something to compare with
  const [at, setAt] = useState(Math.min(1, Math.max(0, initial.length - 1)));
  const [text, setText] = useState('');
  const [busy, setBusy] = useState(false);
  const [peek, setPeek] = useState(false);
  const [used, setUsed] = useState<string | null>(null);
  const cur = steps[at];
  const prev = steps[at - 1];
  const apply = (label: string) => {
    if (!label.trim() || busy) return;
    setBusy(true);
    window.setTimeout(
      () => {
        setBusy(false);
        setAt((a) => Math.min(steps.length - 1, a + 1));
        setText('');
      },
      prefersReducedMotion() ? 0 : 1600,
    );
  };
  return (
    <Stage title={title} className={className}>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start lg:gap-6">
        <Reveal effect="scale" delay={60}>
          <div className="rounded-2xl border border-border bg-card p-2 shadow-[var(--highlight),var(--shadow-md)]">
            <Frame media={peek && prev ? prev.media : cur?.media} aspect="landscape" t={t} develop key={`${at}-${peek}`}>
              {busy && <Rendering />}
              <span className="absolute right-2 top-2 rounded-md bg-[color-mix(in_oklab,black_55%,transparent)] px-2 py-1 text-[11.5px] font-medium text-white backdrop-blur-sm" aria-live="polite">
                {peek && prev ? (at - 1 === 0 ? t.original : fill(t.version, { n: at })) : at === 0 ? t.original : fill(t.version, { n: at + 1 })}
              </span>
            </Frame>
            <div className="flex items-center gap-2 px-1.5 pb-1 pt-2.5">
              <button
                type="button"
                disabled={!prev}
                onPointerDown={() => setPeek(true)}
                onPointerUp={() => setPeek(false)}
                onPointerLeave={() => setPeek(false)}
                onKeyDown={(e) => (e.key === ' ' || e.key === 'Enter') && setPeek(true)}
                onKeyUp={() => setPeek(false)}
                title={t.compare}
                className={cn(quietBtn, 'select-none disabled:opacity-40')}
              >
                <Icon name="columns" size={15} />
                <span>{t.compareShort}</span>
                <span className="sr-only">. {t.compare}</span>
              </button>
              <span className="flex-1" />
              <button type="button" disabled={!cur || used === cur.id} onClick={() => cur && (setUsed(cur.id), onAction?.('use', cur.id))} className={primaryBtn} aria-live="polite">
                <Icon name={cur && used === cur.id ? 'check-circle' : 'check'} size={15} />
                {cur && used === cur.id ? t.usedImage : t.use}
              </button>
            </div>
          </div>
        </Reveal>
        <Reveal delay={160} className="space-y-4">
          <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
            <p className="text-[13px] font-medium text-foreground">{t.refine}</p>
            <div className="mt-3 flex flex-wrap gap-2">
              {suggestions.map((s) => (
                <button key={s.id} type="button" disabled={busy} onClick={() => apply(s.label)} className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50 sm:min-h-9">
                  <Icon name={s.icon ?? 'sparkle'} size={14} className="text-muted-foreground" />
                  {s.label}
                </button>
              ))}
            </div>
            <form
              className="mt-3 flex gap-2"
              onSubmit={(e) => {
                e.preventDefault();
                apply(text);
              }}
            >
              <label className="min-w-0 flex-1">
                <span className="sr-only">{t.refine}</span>
                <input value={text} onChange={(e) => setText(e.target.value)} placeholder={t.refinePlaceholder} className="h-11 w-full rounded-lg border border-border bg-background px-3 text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] focus-visible:outline-none sm:h-9" />
              </label>
              <button type="submit" disabled={busy || !text.trim()} className={primaryBtn}>
                {t.apply}
              </button>
            </form>
          </div>
          <div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]">
            <p className="text-[13px] font-medium text-foreground">{t.versions}</p>
            <ol className="mt-3 grid grid-cols-4 gap-2 lg:grid-cols-2">
              {steps.map((s, i) => (
                <li key={s.id}>
                  <button type="button" aria-label={i === 0 ? t.original : s.label} aria-current={i === at ? 'true' : undefined} onClick={() => setAt(i)} className={cn('block w-full rounded-[10px] p-[2px] text-left transition-colors', i === at ? 'bg-primary' : 'bg-transparent hover:bg-border')}>
                    <span data-clip-ok className="relative block aspect-[4/3] overflow-hidden rounded-lg [&>*]:size-full">{s.media}</span>
                  </button>
                  <p className={cn('mt-1 truncate text-[11.5px]', i === at ? 'text-foreground' : 'text-muted-foreground')}>{i === 0 ? t.original : s.label}</p>
                </li>
              ))}
            </ol>
          </div>
          {note && <p className="px-1 text-[12.5px] leading-relaxed text-muted-foreground">{note}</p>}
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* history                                                             */
/* ------------------------------------------------------------------ */

function History({ t, title, prompt = '', items = [], note, onGenerate, className }: P) {
  const [filter, setFilter] = useState<'all' | GenerationAspect>('all');
  const [text, setText] = useState(prompt);
  const [flash, setFlash] = useState(false);
  // A new prompt renders as a tile at the front, then joins the list
  const [made, setMade] = useState<Generation[]>([]);
  const [job, setJob] = useState<Generation | null>(null);
  const seq = useRef(0);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearInterval(timer.current), []);
  const generate = () => {
    if (!text.trim() || job) return;
    onGenerate?.(text);
    setFlash(false);
    const src = items.find((g) => g.prompt === text) ?? items[0];
    const next: Generation = { id: `new-${++seq.current}`, prompt: text, media: src?.media, aspect: src?.aspect ?? 'square', time: t.justNow };
    if (prefersReducedMotion()) return setMade((m) => [next, ...m]);
    setJob({ ...next, progress: 0 });
    window.clearInterval(timer.current);
    let p = 0;
    timer.current = window.setInterval(() => {
      p += 4;
      if (p < 100) return setJob((j) => j && { ...j, progress: p });
      window.clearInterval(timer.current);
      setJob(null);
      setMade((m) => [next, ...m]);
    }, 70);
  };
  const shown = [...made, ...items].filter((g) => filter === 'all' || (g.aspect ?? 'square') === filter);
  const filters: ('all' | GenerationAspect)[] = ['all', 'square', 'portrait', 'landscape'];
  return (
    <Stage title={title} className={className}>
      <Reveal delay={60}>
        <PromptBar t={t} value={text} onValue={setText} busy={!!job} onSubmit={generate} />
        {flash && (
          <p role="status" className="gen-up mt-2 px-1 text-[12.5px] text-success-foreground">
            {t.copied}
          </p>
        )}
      </Reveal>
      <div className="mt-6 flex flex-wrap items-center justify-between gap-3">
        <p className="text-[14px] font-medium text-foreground">{t.history}</p>
        <div role="group" aria-label={t.aspect} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)] p-1">
          {filters.map((f) => (
            <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className={cn('inline-flex h-11 min-w-11 items-center justify-center rounded-md px-2.5 text-[12.5px] font-medium transition-colors sm:h-8 sm:min-w-0', filter === f ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
              {t[f]}
            </button>
          ))}
        </div>
      </div>
      {shown.length === 0 && !job ? (
        <p className="mt-4 rounded-2xl border border-dashed border-border-strong px-6 py-12 text-center text-[13.5px] text-muted-foreground">{t.empty}</p>
      ) : (
        <ul className="mt-4 columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3">
          {job && (
            <li key={job.id} className="gen-up break-inside-avoid" aria-live="polite">
              <Frame aspect={job.aspect} t={t}>
                <Rendering progress={job.progress} />
              </Frame>
              <p className="mt-1.5 flex items-center justify-between gap-2 px-0.5 text-[11.5px] text-muted-foreground">
                <span className="truncate">{job.prompt}</span>
                <span className="shrink-0 text-primary">{t.generating}</span>
              </p>
            </li>
          )}
          {shown.map((g, i) => (
            <li key={`${filter}-${g.id}`} className="gen-up break-inside-avoid" style={{ '--gen-d': `${Math.min(i, 8) * 60}ms` } as React.CSSProperties}>
              {/* The hover layer keeps the bottom row free for the AI mark */}
              <figure className="group relative">
                <Frame media={g.media} aspect={g.aspect} t={t} develop={g.id.startsWith('new-')}>
                  <span className="absolute inset-0 flex flex-col justify-end bg-[linear-gradient(to_top,color-mix(in_oklab,black_70%,transparent),transparent_60%)] p-3 pb-9 opacity-0 transition-opacity duration-300 group-focus-within:opacity-100 group-hover:opacity-100">
                    <span className="line-clamp-3 text-[12.5px] leading-snug text-white">{g.prompt}</span>
                    <button type="button" onClick={() => (setText(g.prompt), setFlash(true))} className="mt-2 inline-flex h-9 w-fit items-center gap-1.5 rounded-md bg-white/90 px-2.5 text-[12px] font-medium text-[#111] max-sm:hidden">
                      <Icon name="edit" size={13} />
                      {t.reuse}
                    </button>
                  </span>
                </Frame>
                <figcaption className="mt-1.5 flex items-center justify-between gap-2 px-0.5 text-[11.5px] text-muted-foreground">
                  <span className="truncate">{g.prompt}</span>
                  <span className="shrink-0 tabular-nums max-sm:hidden">{g.time}</span>
                  {/* Phones have no hover, the prompt is taken over from the caption */}
                  <button type="button" aria-label={`${t.reuse}, ${g.prompt}`} onClick={() => (setText(g.prompt), setFlash(true))} className="-my-3 -mr-2 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground hover:text-foreground sm:hidden">
                    <Icon name="edit" size={14} />
                  </button>
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      )}
      {note && <p className="mt-2 px-1 text-[12.5px] text-muted-foreground">{note}</p>}
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* queue                                                               */
/* ------------------------------------------------------------------ */

function Queue({ t, title, items: initial = [], note, className }: P) {
  const de = useLocale() === 'de';
  const [items, setItems] = useSynced(initial);
  const [ref, inView] = useInView<HTMLDivElement>();
  // Running jobs move on, a finished one hands over to the next in line
  useEffect(() => {
    if (!inView || prefersReducedMotion()) return;
    const id = window.setInterval(() => {
      setItems((cur) => {
        const next = cur.map((g) => (g.status === 'rendering' ? { ...g, progress: Math.min(100, (g.progress ?? 0) + 1) } : g));
        // A retried job waits in line and starts as soon as nothing else renders
        if (!next.some((g) => g.status === 'rendering')) {
          const first = next.findIndex((g) => g.status === 'queued');
          return first < 0 ? next : next.map((g, i) => (i === first ? { ...g, status: 'rendering' as const, progress: 0 } : g));
        }
        const done = next.findIndex((g) => g.status === 'rendering' && (g.progress ?? 0) >= 100);
        if (done < 0) return next;
        const waiting = next.findIndex((g) => g.status === 'queued');
        return next.map((g, i) => (i === done ? { ...g, status: 'done' as const } : i === waiting ? { ...g, status: 'rendering' as const, progress: 0 } : g));
      });
    }, 160);
    return () => window.clearInterval(id);
  }, [inView, setItems]);

  const label = (g: Generation) => (g.status === 'rendering' ? t.queue : g.status === 'queued' ? t.queued : g.status === 'failed' ? t.failed : t.done);
  return (
    <Stage title={title} className={className}>
      <div ref={ref}>
        <Stagger as="ul" step={80} className="grid grid-cols-2 gap-2 sm:gap-3 lg:grid-cols-3">
          {items.map((g) => {
            const st = g.status ?? 'done';
            return (
              <li key={g.id} className="flex flex-col rounded-2xl border border-border bg-card p-2 shadow-[var(--highlight),var(--shadow-sm)]">
                <Frame media={st === 'done' ? g.media : null} aspect="landscape" t={t} develop={st === 'done'}>
                  {(st === 'rendering' || st === 'queued') && <Rendering progress={st === 'rendering' ? g.progress ?? 0 : undefined} className={st === 'queued' ? 'opacity-60' : undefined} />}
                  {st === 'failed' && (
                    <span className="absolute inset-0 grid place-items-center bg-[color-mix(in_oklab,var(--destructive)_8%,var(--surface-2))] text-destructive">
                      <Icon name="alert-circle" size={26} />
                    </span>
                  )}
                </Frame>
                <div className="flex min-h-0 flex-1 flex-col px-1.5 pb-1 pt-2.5 sm:px-2 sm:pb-1.5 sm:pt-3">
                  <p className="line-clamp-2 text-[13px] leading-snug text-foreground sm:text-[13.5px]">{g.prompt}</p>
                  {st === 'failed' && <p className="mt-1 text-[12.5px] leading-snug text-destructive">{g.error ?? t.failedNote}</p>}
                  <div className="mt-auto flex min-w-0 items-center justify-between gap-1 pt-3 text-[12px]" aria-live="polite">
                    <span className={cn('inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap font-medium', st === 'done' ? 'text-success-foreground' : st === 'failed' ? 'text-destructive' : st === 'rendering' ? 'text-primary' : 'text-muted-foreground')}>
                      <span className={cn('size-1.5 rounded-full', st === 'done' ? 'bg-success' : st === 'failed' ? 'bg-destructive' : st === 'rendering' ? 'animate-kit-pulse bg-primary' : 'bg-muted-foreground')} />
                      <span className={st === 'rendering' ? 'max-sm:sr-only' : undefined}>{label(g)}</span>
                      {st === 'rendering' && <span className="tabular-nums text-muted-foreground">{Math.round(g.progress ?? 0)}{de ? ' %' : '%'}</span>}
                    </span>
                    {st === 'rendering' || st === 'queued' ? (
                      <button type="button" aria-label={fill(t.cancel, { name: g.prompt })} onClick={() => setItems((s) => s.filter((x) => x.id !== g.id))} className="-my-2 grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
                        <Icon name="x" size={15} />
                      </button>
                    ) : st === 'failed' ? (
                      <button type="button" onClick={() => setItems((s) => s.map((x) => (x.id === g.id ? { ...x, status: 'queued' } : x)))} className="-my-2 inline-flex h-11 min-w-11 items-center justify-center gap-1 rounded-lg px-2 font-medium text-foreground hover:bg-accent sm:h-8 sm:min-w-0">
                        <Icon name="refresh" size={13} />
                        <span className="max-sm:sr-only">{t.retry}</span>
                      </button>
                    ) : (
                      <span className="text-muted-foreground">{g.time}</span>
                    )}
                  </div>
                </div>
              </li>
            );
          })}
        </Stagger>
      </div>
      {note && <p className="mt-4 px-1 text-[12.5px] text-muted-foreground">{note}</p>}
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * Generated images: four variations to pick from with vary and upscale,
 * refining one image step by step with versions to compare, the history of
 * past images by format and a render queue with progress and retry. Images
 * come in as slots, every frame carries a mark that it was made with AI.
 */
export function GenerationGallery({ variant = 'variations', labels, ...props }: GenerationGalleryProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'refine':
        return <Refine {...p} />;
      case 'history':
        return <History {...p} />;
      case 'queue':
        return <Queue {...p} />;
      default:
        return <Variations {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich Bildergalerie für KI?

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

Welche Varianten hat Bildergalerie für KI?

4 Varianten, Varianten, Schritt für Schritt verfeinern, Verlauf und Warteschlange. Du wählst sie mit der Prop variant, zum Beispiel variant="refine".

Kann ich die Texte von Bildergalerie für KI übersetzen?

Ja. Bildergalerie für KI bringt 38 eigene Texte auf Deutsch und Englisch mit, etwa „Beschreib das Bild“, „Erzeugen“ und „Wird erzeugt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Bildergalerie für KI einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bildergalerie für KI mit aiw_components und installieren sie mit aiw_add @ai-website/generation-gallery. Nenn im Prompt die Variante, etwa variant="variations", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Bildergalerie für KI ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert