Mikkel Manniche
Dansk English Deutsch

KI-Agenten

Image generation

Eine Kachel, die zeigt, wie ein Bild entsteht: ein weiches Leuchten, das mit dem Fortschritt zum Ergebnis scharf wird.

Vorschau

Verwendung

image-generation-demo.tsx
import { RotateCcw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { ImageGeneration } from '@/components/image-generation'

export default function ImageGenerationDemo() {
  const [progress, setProgress] = useState(0)
  const [run, setRun] = useState(0)

  useEffect(() => {
    const id = setInterval(() => setProgress((p) => Math.min(1, p + 0.04 + Math.random() * 0.05)), 260)
    return () => clearInterval(id)
  }, [run])

  return (
    <div className="flex flex-col items-center gap-4">
      <ImageGeneration progress={progress} size="1024 × 1024" prompt="A ceramic mug on a linen table in soft morning light">
        {/* Stand-in for a real <img>: a mug drawn with gradients. */}
        <div className="relative size-full bg-[radial-gradient(ellipse_at_50%_115%,#c9b79c_0_38%,transparent_39%),linear-gradient(#efe6d8,#e2d3bd)]">
          <div className="absolute top-[30%] left-[30%] h-[42%] w-[34%] rounded-t-md rounded-b-[28%] bg-[linear-gradient(90deg,#4c6b73,#7d9ea5_45%,#3d5960)]" />
          <div className="absolute top-[38%] left-[61%] h-[22%] w-[13%] rounded-r-full border-[7px] border-l-0 border-[#56767e]" />
        </div>
      </ImageGeneration>
      <button
        type="button"
        onClick={() => {
          setProgress(0)
          setRun((r) => r + 1)
        }}
        className="inline-flex min-h-11 items-center gap-2 rounded-full border px-4 text-sm transition-colors duration-150 hover:bg-muted"
      >
        <RotateCcw className="size-4" aria-hidden />
        Generate again
      </button>
    </div>
  )
}

Quellcode

image-generation.tsx
import { Check, Sparkles } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type ImageGenerationProps = {
  /** 0 to 1. At 1 the result is shown. */
  progress: number
  /** The finished image, usually an <img>. Rendered behind the blur while it sharpens. */
  children?: ReactNode
  prompt?: string
  /** A small badge in the corner, e.g. “1024 × 1024”. */
  size?: string
  labels?: Partial<Record<'working' | 'ready', string>>
  className?: string
}

const EN = { working: 'Creating image', ready: 'Image ready' }

/** A tile that shows an image being made: a soft glow that sharpens into the result as progress rises. */
export function ImageGeneration({ progress, children, prompt, size, labels = {}, className }: ImageGenerationProps) {
  const t = { ...EN, ...labels }
  const p = Math.min(1, Math.max(0, progress))
  const done = p >= 1

  return (
    <MotionConfig reducedMotion="user">
      <figure className={cn('w-full max-w-xs', className)}>
        <div
          role="img"
          aria-label={done ? (prompt ?? t.ready) : `${t.working}, ${Math.round(p * 100)}%`}
          className="relative isolate aspect-square overflow-hidden rounded-3xl border bg-muted"
        >
          {/* The result sits underneath from the start and comes into focus as progress rises. */}
          <motion.div
            className="absolute inset-0"
            initial={false}
            animate={{ filter: `blur(${done ? 0 : 28 - p * 20}px)`, scale: done ? 1 : 1.12 - p * 0.06, opacity: done ? 1 : 0.15 + p * 0.55 }}
            transition={{ type: 'spring', bounce: 0, duration: done ? 0.9 : 0.6 }}
          >
            {children}
          </motion.div>

          {/* Drifting light while the image is being made. */}
          <AnimatePresence>
            {!done && (
              <motion.div key="glow" className="absolute inset-0" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.6 } }}>
                <span className="absolute -inset-1/4 animate-[spin_9s_linear_infinite] bg-[conic-gradient(from_0deg,transparent,color-mix(in_oklch,var(--color-primary)_35%,transparent),transparent_40%,color-mix(in_oklch,var(--color-foreground)_18%,transparent),transparent_75%)] blur-2xl motion-reduce:animate-none" />
                <span className="absolute inset-x-0 bottom-0 h-1 bg-foreground/10">
                  <motion.span className="block h-full origin-left bg-foreground/70" initial={false} animate={{ scaleX: p }} transition={{ type: 'spring', bounce: 0, duration: 0.5 }} />
                </span>
              </motion.div>
            )}
          </AnimatePresence>

          {size && <span className="absolute top-2.5 right-2.5 rounded-full bg-background/70 px-2 py-0.5 font-mono text-[11px] text-muted-foreground backdrop-blur">{size}</span>}
        </div>

        <figcaption className="mt-3" aria-live="polite">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.p
              key={done ? 'ready' : 'working'}
              initial={{ opacity: 0, y: 6, filter: 'blur(2px)' }}
              animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
              exit={{ opacity: 0, y: -6, filter: 'blur(2px)' }}
              transition={{ type: 'spring', bounce: 0, duration: 0.35 }}
              className="flex items-center gap-2 text-sm font-medium"
            >
              {done ? <Check className="size-4" aria-hidden /> : <Sparkles className="size-4 animate-pulse motion-reduce:animate-none" aria-hidden />}
              {done ? t.ready : t.working}
              {!done && <span aria-hidden className="ml-auto font-mono text-xs text-muted-foreground tabular-nums">{Math.round(p * 100)}%</span>}
            </motion.p>
          </AnimatePresence>
          {prompt && <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">“{prompt}”</p>}
        </figcaption>
      </figure>
    </MotionConfig>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/image-generation.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/image-generation

Dateien

  • image-generation.tsx