Image Effects
ChromaticShift
The picture holds three colour layers that line up at rest. Under the pointer they part toward it, then spring back. No WebGL, so pictures from other sites work.
Download
- React
- Tailwind CSS v4
- motion
components/ui/chromatic-shift.tsx
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react";
type ChromaticShiftProps = {
src: string;
alt: string;
/** How far the colours part, in percent of the picture's size. */
amount?: number;
/** Set the size here. */
className?: string;
};
type ChannelProps = {
src: string;
alt: string;
colour: string;
sign: number;
shiftX: MotionValue<number>;
shiftY: MotionValue<number>;
};
const SPLIT = { stiffness: 180, damping: 14 };
const GROW = { stiffness: 260, damping: 30 };
function Channel({ src, alt, colour, sign, shiftX, shiftY }: ChannelProps) {
const x = useTransform(shiftX, (v) => `${v * sign}%`);
const y = useTransform(shiftY, (v) => `${v * sign}%`);
return (
<motion.div className="absolute inset-0 isolate mix-blend-screen" style={{ x, y }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} draggable={false} className="absolute inset-0 size-full object-cover" />
<div className="absolute inset-0 mix-blend-multiply" style={{ backgroundColor: colour }} />
</motion.div>
);
}
/* ChromaticShift: the picture holds three colour layers that line up at rest. Under the pointer they part toward it, then spring back. */
export function ChromaticShift({ src, alt, amount = 2, className = "aspect-video" }: ChromaticShiftProps) {
const reduce = useReducedMotion();
const x = useMotionValue(0);
const y = useMotionValue(0);
const power = useMotionValue(0);
const shiftX = useSpring(x, SPLIT);
const shiftY = useSpring(y, SPLIT);
const smoothPower = useSpring(power, GROW);
const scale = useTransform(smoothPower, [0, 1], [1, 1 + amount / 50]);
const aim = (e: React.PointerEvent<HTMLDivElement>) => {
if (reduce) return;
const r = e.currentTarget.getBoundingClientRect();
const nx = ((e.clientX - r.left) / r.width) * 2 - 1;
const ny = ((e.clientY - r.top) / r.height) * 2 - 1;
const length = Math.hypot(nx, ny) || 1;
const reach = (Math.max(length, 0.6) / length) * amount;
x.set(nx * reach);
y.set(ny * reach);
power.set(1);
};
const rest = () => {
x.set(0);
y.set(0);
power.set(0);
};
return (
<div onPointerEnter={aim} onPointerDown={aim} onPointerMove={aim} onPointerLeave={rest} className={`relative isolate touch-pan-y select-none overflow-hidden bg-black ${className}`}>
<motion.div className="absolute inset-0" style={{ scale }}>
<Channel src={src} alt={alt} colour="#f00" sign={1} shiftX={shiftX} shiftY={shiftY} />
<Channel src={src} alt="" colour="#0f0" sign={0} shiftX={shiftX} shiftY={shiftY} />
<Channel src={src} alt="" colour="#00f" sign={-1} shiftX={shiftX} shiftY={shiftY} />
</motion.div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/chromatic-shift.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/chromatic-shift-demo.tsx
import { ChromaticShift } from "@/components/ui/chromatic-shift"; export default function ChromaticShiftExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <ChromaticShift src="/demo/dusk.jpg" alt="Hills at dusk under a pale sun" className="aspect-video w-full max-w-[520px] rounded-2xl" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | — | Picture address. |
| alt | string | — | Text for screen readers. |
| amount | number | 2 | How far the colours part, in percent of the picture size. |
| className | string | "aspect-video" | Set the size here. |