Image effects
Treatments for any picture. A duotone in the accent turns to full color on hover, reveals uncover it while you scroll, a liquid ripple runs across it, a spotlight of color moves over a gray version and slices arrive one by one. Only filter, clip-path, mask and transform move.
One of 3 components in Patterns and shapes
Variants
Details
What Image effects brings along, read from the registry and the source.
- Category
- Patterns and shapes
- Area
- Visuals
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/image-effects.tsx
- Brings along
- utilskitmotion
- npm packages
- None
- Languages
- Texts come through props
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ImageEffectProps interface from components/ui/image-effects.tsx, with the comments from the code.
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
childrenrequiredReact.ReactNode
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 5 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 284 lines
'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>
);
}Frequent questions
How do I install Image effects?
With aiw add @ai-website/image-effects. The command puts components/ui/image-effects.tsx into your project, the code is yours from then on. It brings along motion. It needs no further npm packages.
Which variants does Image effects have?
5 variants, Duotone, Scroll reveal, Liquid ripple, Color spotlight and Slices.
Can an AI assistant build with Image effects?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Image effects with aiw_components and install it with aiw_add @ai-website/image-effects.
Can I adjust Image effects without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching ImageEffect code to copy.