Zum Inhalt springen
UI Kit
Accent

Bildeffekte

Behandlungen für jedes Bild. Ein Duoton im Akzent wird beim Hover farbig, Enthüllungen zeigen es beim Scrollen, eine flüssige Welle läuft darüber, ein Farbspot wandert über die graue Fassung, und Streifen kommen nacheinander an. Bewegt werden nur Filter, Clip-Path, Maske und Transform.

Eine von 3 Components in Muster und Formen

5 Variants

Variants

Duoton

Porträts in zwei Tönen des Akzents, so wirken Fotos aus verschiedenen Shootings wie ein Set. Hover oder Fokus bringt die Farbe zurück.

Im Studio öffnen

Enthüllen beim Scrollen

Vier Arten, wie ein Bild sich beim Hereinscrollen zeigt, als Wischen, als Vorhang aus der Mitte, als wachsender Kreis oder als Jalousie. Gleichzeitig setzt sich das Bild aus leichtem Zoom.

Im Studio öffnen

Flüssige Welle

Eine weiche Verzerrung, die dem Zeiger folgt und sich beruhigt, wenn er geht. Auf dem Telefon setzt ein Tippen sie kurz in Bewegung.

Im Studio öffnen

Farbspot

Ein graues Bild mit einem Kreis in voller Farbe. Mit der Maus folgt er dem Zeiger, auf dem Telefon wandert er beim Scrollen über das Bild.

Im Studio öffnen

Streifen

Das Bild kommt in senkrechten Streifen von oben und unten an, danach verschieben sich die Streifen leicht mit dem Zeiger. Ein anderes Bild spielt es neu ab.

Im Studio öffnen

Details

Was Bildeffekte mitbringt, aus Registry und Quellcode gelesen.

Bereich
Visuals
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/image-effects.tsx
Bringt mit
utilskitmotion
npm-Pakete
Keine
Sprachen
Texte kommen über Props

Props

Die Schnittstelle ImageEffectProps aus components/ui/image-effects.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • effectoptional
    ImageEffectKind

    duotonerevealdistortspotlightslices

  • shadowoptional
    string

    duotone: the dark and the light color, any CSS color. Default a deep and a pale shade of the accent, the same in light and dark mode like a printed picture.

  • highlightoptional
    string
  • colorOnHoveroptional
    boolean

    duotone: show full color on hover and focus. Default true.

  • shapeoptional
    RevealShape

    wipecurtaincircleblinds

    reveal

  • scrolloptional
    boolean

    reveal: follow the scroll (default) or play once when in view

  • strengthoptional
    number

    distort: strength of the ripple, 0 to 60. Default 24.

  • radiusoptional
    number

    spotlight: radius of the light in pixels. Default 140.

  • countoptional
    number

    slices: how many. Default 5.

  • ratiooptional
    string

    Aspect ratio of the frame, e.g. "4 / 3". Default "16 / 10".

  • roundedoptional
    string

    Corner radius class, default rounded-2xl

  • labeloptional
    string

    Accessible description of the picture, if the child does not carry one

  • childrenPflicht
    React.ReactNode

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle label.
  • 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. 5 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 284 Zeilen
components/ui/image-effects.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView } from '@/lib/kit';
import { useScrollProgress } from '@/components/ui/motion';

/*
 * Effects for pictures. Wrap any image (an <img>, a picture, a drawn motif)
 * and it gets a treatment that fits the page:
 *
 *   duotone    two theme colors instead of the photo's own, full color on hover or focus
 *   reveal     the picture uncovers while it scrolls in: wipe, curtain, circle or blinds
 *   distort    a liquid ripple that follows the pointer, a tap on phones
 *   spotlight  grey with a circle of color under the pointer, on phones the light travels with the scroll
 *   slices     the picture arrives in vertical slices and they shift a little with the pointer
 *
 * Server-rendered as the plain finished picture. Only filter, clip-path,
 * mask, opacity and transform move. With reduced motion the picture simply stands.
 */

export type ImageEffectKind = 'duotone' | 'reveal' | 'distort' | 'spotlight' | 'slices';
export type RevealShape = 'wipe' | 'curtain' | 'circle' | 'blinds';

export interface ImageEffectProps {
  effect?: ImageEffectKind;
  /** duotone: the dark and the light color, any CSS color. Default a deep and a pale shade of the accent, the same in light and dark mode like a printed picture. */
  shadow?: string;
  highlight?: string;
  /** duotone: show full color on hover and focus. Default true. */
  colorOnHover?: boolean;
  /** reveal */
  shape?: RevealShape;
  /** reveal: follow the scroll (default) or play once when in view */
  scroll?: boolean;
  /** distort: strength of the ripple, 0 to 60. Default 24. */
  strength?: number;
  /** spotlight: radius of the light in pixels. Default 140. */
  radius?: number;
  /** slices: how many. Default 5. */
  count?: number;
  /** Aspect ratio of the frame, e.g. "4 / 3". Default "16 / 10". */
  ratio?: string;
  /** Corner radius class, default rounded-2xl */
  rounded?: string;
  /** Accessible description of the picture, if the child does not carry one */
  label?: string;
  className?: string;
  children: React.ReactNode;
}

/** The picture fills the frame, whatever it is */
function Fill({ children, className, style }: { children: React.ReactNode; className?: string; style?: React.CSSProperties }) {
  return (
    <div className={cn('absolute inset-0 [&>*]:size-full [&>img]:object-cover', className)} style={style}>
      {children}
    </div>
  );
}

/* ---------- duotone ---------- */

function Duotone({ shadow = 'color-mix(in oklab, var(--primary) 35%, black)', highlight = 'color-mix(in oklab, var(--primary) 30%, white)', colorOnHover = true, children }: Pick<ImageEffectProps, 'shadow' | 'highlight' | 'colorOnHover' | 'children'>) {
  const hover = colorOnHover ? 'group-hover/fx:opacity-0 group-focus-within/fx:opacity-0' : '';
  return (
    <>
      <Fill className={cn('transition-[filter] duration-700 ease-[var(--ease-kit)] [filter:grayscale(1)_contrast(1.15)]', colorOnHover && 'group-hover/fx:[filter:none] group-focus-within/fx:[filter:none]')}>{children}</Fill>
      {/* Highlights take the light color, shadows the dark one */}
      <div aria-hidden className={cn('absolute inset-0 mix-blend-multiply transition-opacity duration-700 ease-[var(--ease-kit)]', hover)} style={{ background: highlight }} />
      <div aria-hidden className={cn('absolute inset-0 mix-blend-lighten transition-opacity duration-700 ease-[var(--ease-kit)]', hover)} style={{ background: shadow }} />
    </>
  );
}

/* ---------- reveal ---------- */

function Reveal({ shape = 'wipe', scroll = true, children }: Pick<ImageEffectProps, 'shape' | 'scroll' | 'children'>) {
  // Done by the time the picture sits in the lower half of the window, so even the last picture on a page finishes
  const ref = useScrollProgress<HTMLDivElement>({ start: 0.02, end: 0.3 });
  const [view, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');
  const p = scroll ? 'var(--kit-progress, 1)' : inView ? '1' : '0';
  const clip: Record<Exclude<RevealShape, 'blinds'>, string> = {
    wipe: `inset(0 calc((1 - ${p}) * 100%) 0 0)`,
    curtain: `inset(0 calc((1 - ${p}) * 50%) 0 calc((1 - ${p}) * 50%))`,
    circle: `circle(calc(${p} * 75%) at 50% 55%)`,
  };
  const ease = scroll ? '' : 'transition-[clip-path,transform] duration-[1200ms] ease-[var(--ease-kit)]';
  return (
    <div ref={scroll ? ref : view} className="absolute inset-0">
      {shape === 'blinds' ? (
        Array.from({ length: 6 }, (_, i) => (
          <div
            key={i}
            className={cn('absolute inset-0 motion-reduce:![clip-path:none]', ease)}
            style={{
              clipPath: `inset(${round((i / 6) * 100, 3)}% 0 ${round(((5 - i) / 6) * 100, 3)}% calc((1 - min(1, max(0, ${p} * 2.2 - ${round(i * 0.12, 2)}))) * 100%))`,
              transitionDelay: scroll ? undefined : `${i * 70}ms`,
            }}
          >
            <Fill>{children}</Fill>
          </div>
        ))
      ) : (
        <div className={cn('absolute inset-0 motion-reduce:![clip-path:none]', ease)} style={{ clipPath: clip[shape] }}>
          <Fill className="motion-reduce:!transform-none" style={{ transform: `scale(calc(1.12 - ${p} * 0.12))`, transformOrigin: '50% 60%', transition: scroll ? undefined : 'transform 1400ms var(--ease-kit)' }}>
            {children}
          </Fill>
        </div>
      )}
    </div>
  );
}

/* ---------- distort ---------- */

function Distort({ strength = 24, children }: Pick<ImageEffectProps, 'strength' | 'children'>) {
  const id = `kie${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
  const disp = useRef<SVGFEDisplacementMapElement>(null);
  const turb = useRef<SVGFETurbulenceElement>(null);
  const target = useRef(0);
  const current = useRef(0);
  const frame = useRef(0);
  const t = useRef(0);
  const run = () => {
    if (frame.current) return;
    const step = () => {
      current.current += (target.current - current.current) * 0.08;
      t.current += 0.012;
      disp.current?.setAttribute('scale', current.current.toFixed(2));
      turb.current?.setAttribute('baseFrequency', `${(0.012 + Math.sin(t.current) * 0.003).toFixed(4)} ${(0.02 + Math.cos(t.current * 0.8) * 0.004).toFixed(4)}`);
      if (Math.abs(target.current - current.current) < 0.05 && target.current === 0) {
        frame.current = 0;
        disp.current?.setAttribute('scale', '0');
        return;
      }
      frame.current = requestAnimationFrame(step);
    };
    frame.current = requestAnimationFrame(step);
  };
  useEffect(() => () => cancelAnimationFrame(frame.current), []);
  const go = (to: number) => {
    if (prefersReducedMotion()) return;
    target.current = to;
    run();
  };
  return (
    <div
      className="absolute inset-0"
      onPointerEnter={(e) => e.pointerType === 'mouse' && go(strength)}
      onPointerMove={(e) => {
        if (e.pointerType !== 'mouse') return;
        const r = e.currentTarget.getBoundingClientRect();
        const dx = (e.clientX - r.left) / r.width - 0.5;
        const dy = (e.clientY - r.top) / r.height - 0.5;
        go(strength * (0.6 + Math.min(1, Math.hypot(dx, dy) * 1.6) * 0.6));
      }}
      onPointerLeave={() => go(0)}
      onPointerUp={(e) => {
        if (e.pointerType === 'mouse') return;
        go(strength);
        window.setTimeout(() => go(0), 700);
      }}
    >
      <svg aria-hidden className="absolute size-0">
        <filter id={id} x="-10%" y="-10%" width="120%" height="120%">
          <feTurbulence ref={turb} type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="3" />
          <feDisplacementMap ref={disp} in="SourceGraphic" scale="0" xChannelSelector="R" yChannelSelector="G" />
        </filter>
      </svg>
      <Fill className="scale-[1.04]" style={{ filter: `url(#${id})` }}>
        {children}
      </Fill>
    </div>
  );
}

/* ---------- spotlight ---------- */

function Spotlight({ radius = 140, children }: Pick<ImageEffectProps, 'radius' | 'children'>) {
  const ref = useScrollProgress<HTMLDivElement>({ start: 0.1, end: 0.9 });
  const [mouse, setMouse] = useState(false);
  return (
    <div
      ref={ref}
      className="absolute inset-0"
      onPointerMove={(e) => {
        if (e.pointerType !== 'mouse') return;
        const r = e.currentTarget.getBoundingClientRect();
        e.currentTarget.style.setProperty('--sx', `${round(e.clientX - r.left)}px`);
        e.currentTarget.style.setProperty('--sy', `${round(e.clientY - r.top)}px`);
        if (!mouse) setMouse(true);
      }}
      onPointerLeave={() => setMouse(false)}
    >
      <Fill className="[filter:grayscale(1)_brightness(0.85)]">{children}</Fill>
      <Fill
        className="motion-reduce:![mask-image:none]"
        style={{
          // With a mouse the light follows it, otherwise it travels across with the scroll
          maskImage: `radial-gradient(${radius}px circle at ${mouse ? 'var(--sx) var(--sy)' : 'calc(10% + var(--kit-progress, 0.5) * 80%) 45%'}, #000 55%, transparent 100%)`,
          WebkitMaskImage: `radial-gradient(${radius}px circle at ${mouse ? 'var(--sx) var(--sy)' : 'calc(10% + var(--kit-progress, 0.5) * 80%) 45%'}, #000 55%, transparent 100%)`,
        }}
      >
        {children}
      </Fill>
    </div>
  );
}

/* ---------- slices ---------- */

function Slices({ count = 5, children }: Pick<ImageEffectProps, 'count' | 'children'>) {
  const [ref, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');
  const [shift, setShift] = useState(0);
  const n = Math.max(2, Math.min(9, Math.round(count)));
  return (
    <div
      ref={ref}
      className="absolute inset-0"
      onPointerMove={(e) => {
        if (e.pointerType !== 'mouse' || prefersReducedMotion()) return;
        const r = e.currentTarget.getBoundingClientRect();
        setShift(round(((e.clientX - r.left) / r.width - 0.5) * 2, 3));
      }}
      onPointerLeave={() => setShift(0)}
    >
      {/* The whole picture underneath once every slice has landed, so no seam can show */}
      <Fill className="transition-opacity duration-300" style={{ opacity: inView ? 1 : 0, transitionDelay: inView ? `${n * 80 + 700}ms` : '0ms' }}>
        {children}
      </Fill>
      {Array.from({ length: n }, (_, i) => {
        const offset = (i - (n - 1) / 2) / ((n - 1) / 2);
        return (
          <div
            key={i}
            className="absolute inset-0 transition-transform duration-[900ms] ease-[var(--ease-kit)] motion-reduce:!transform-none"
            style={{
              // Each slice reaches half a pixel into its neighbour, so no seam shows between them
              clipPath: `inset(0 calc(${round(((n - 1 - i) / n) * 100, 3)}% - 1px) 0 calc(${round((i / n) * 100, 3)}% - 1px))`,
              transform: inView ? `translateY(${round(shift * offset * 10, 1)}px)` : `translateY(${i % 2 ? 18 : -18}%)`,
              opacity: inView ? 1 : 0,
              transitionDelay: inView && shift === 0 ? `${i * 80}ms` : '0ms',
              transitionProperty: 'transform, opacity',
            }}
          >
            <Fill>{children}</Fill>
          </div>
        );
      })}
    </div>
  );
}

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

/**
 * A picture with an effect.
 *   <ImageEffect effect="duotone" ratio="4 / 5"><img src="…" alt="…" /></ImageEffect>
 */
export function ImageEffect({ effect = 'duotone', shadow, highlight, colorOnHover, shape, scroll, strength, radius, count, ratio = '16 / 10', rounded = 'rounded-2xl', label, className, children }: ImageEffectProps) {
  return (
    <div
      role={label ? 'img' : undefined}
      aria-label={label}
      tabIndex={effect === 'duotone' && colorOnHover !== false ? 0 : undefined}
      data-clip-ok
      className={cn('group/fx relative isolate w-full overflow-hidden bg-muted outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]', rounded, className)}
      style={{ aspectRatio: ratio }}
    >
      {effect === 'duotone' && (
        <Duotone shadow={shadow} highlight={highlight} colorOnHover={colorOnHover}>
          {children}
        </Duotone>
      )}
      {effect === 'reveal' && (
        <Reveal shape={shape} scroll={scroll}>
          {children}
        </Reveal>
      )}
      {effect === 'distort' && <Distort strength={strength}>{children}</Distort>}
      {effect === 'spotlight' && <Spotlight radius={radius}>{children}</Spotlight>}
      {effect === 'slices' && <Slices count={count}>{children}</Slices>}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Bildeffekte?

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

Welche Varianten hat Bildeffekte?

5 Varianten, Duoton, Enthüllen beim Scrollen, Flüssige Welle, Farbspot und Streifen.

Kann ein KI-Assistent Bildeffekte einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bildeffekte mit aiw_components und installieren sie mit aiw_add @ai-website/image-effects.

Kann ich Bildeffekte ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert