Skip to content
UI Kit
Accent color

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

5 variants

Variants

Duotone

Portraits in two shades of the accent, so photos from different shoots look like one set. Hover or focus brings the color back.

Open in Studio

Scroll reveal

Four ways a picture uncovers while it scrolls in, as a wipe, a curtain from the middle, a growing circle or blinds. The picture settles from a slight zoom at the same time.

Open in Studio

Liquid ripple

A soft displacement that follows the pointer and settles when it leaves. On phones a tap sets it moving for a moment.

Open in Studio

Color spotlight

A gray picture with a circle of full color. With a mouse it follows the pointer, on phones it travels across the picture while you scroll.

Open in Studio

Slices

The picture arrives in vertical slices from above and below, then the slices shift a little with the pointer. Switching the picture plays it again.

Open in Studio

Details

What Image effects brings along, read from the registry and the source.

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

Props

The ImageEffectProps interface from components/ui/image-effects.tsx, with the comments from the code.

  • 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

  • childrenrequired
    React.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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard