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
Variants
Details
Was Bildeffekte mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Muster und Formen
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ImageEffectProps aus components/ui/image-effects.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
effectoptionalImageEffectKindduotonerevealdistortspotlightslices
shadowoptionalstringduotone: 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.
highlightoptionalstringcolorOnHoveroptionalbooleanduotone: show full color on hover and focus. Default true.
shapeoptionalRevealShapewipecurtaincircleblinds
reveal
scrolloptionalbooleanreveal: follow the scroll (default) or play once when in view
strengthoptionalnumberdistort: strength of the ripple, 0 to 60. Default 24.
radiusoptionalnumberspotlight: radius of the light in pixels. Default 140.
countoptionalnumberslices: how many. Default 5.
ratiooptionalstringAspect ratio of the frame, e.g. "4 / 3". Default "16 / 10".
roundedoptionalstringCorner radius class, default rounded-2xl
labeloptionalstringAccessible description of the picture, if the child does not carry one
childrenPflichtReact.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
'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.