Clip path image reveal
Each image opens from a small inset window to full size, settling from a slight zoom, as it scrolls into view.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import styles from "./demo.module.css";
const blocks = [
{ title: "The pool at Casa Mar", meta: "Comporta, Portugal" },
{ title: "Morning on the terrace", meta: "Olive House, Paxos" },
{ title: "Garden suite", meta: "Villa Azul, Goa" },
{ title: "Path to the beach", meta: "Palm Court, Galle" },
];
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const scroller = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scroller.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => entries.forEach((entry) => entry.target.classList.toggle(styles.shown, entry.isIntersecting)),
{ root: el, threshold: 0.3 },
);
el.querySelectorAll(`.${styles.figure}`).forEach((node) => observer.observe(node));
const onScroll = () => {
if (el.scrollTop > 4) root.current!.classList.add(styles.scrolled);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
observer.disconnect();
el.removeEventListener("scroll", onScroll);
};
}, []);
return (
<div ref={root} className={styles.root}>
<div ref={scroller} className={styles.scroller} tabIndex={0} aria-label="Gallery">
<div className={styles.inner}>
<p className={styles.lead}>A look around the collection</p>
{blocks.map((b, i) => (
<figure key={b.title} className={styles.figure} data-art={i}>
<div className={styles.frame}><div className={styles.art} /></div>
<figcaption><b>{b.title}</b><span>{b.meta}</span></figcaption>
</figure>
))}
</div>
</div>
<span className={styles.hint} aria-hidden="true">Scroll inside</span>
</div>
);
}
demo.module.css
.root { position: relative; width: 100%; height: 100%; background: var(--white); overflow: hidden; }
.scroller { height: 100%; overflow-y: auto; overscroll-behavior: contain; container-type: size; }
.scroller:focus-visible { outline-offset: -3px; }
.inner { max-width: 620px; margin: 0 auto; padding: 20px 20px 40px; display: grid; gap: 28px; }
.lead { font-size: 20px; font-weight: 650; letter-spacing: -0.02em; min-height: 40cqh; display: flex; align-items: center; }
.figure { margin: 0; display: grid; gap: 10px; }
.frame { height: clamp(150px, 70cqh, 340px); clip-path: inset(16% 14% round 16px); transition: clip-path 1s var(--out); }
.art { height: 100%; scale: 1.18; transition: scale 1.2s var(--out); }
.shown .frame { clip-path: inset(0 round 16px); }
.shown .art { scale: 1; }
.figure figcaption { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; opacity: 0; translate: 0 8px; transition: opacity .5s .3s, translate .6s .3s var(--out); }
.figure figcaption span { color: var(--muted); }
.figure.shown figcaption { opacity: 1; translate: 0 0; }
.figure[data-art="0"] .art { background: linear-gradient(180deg, transparent 58%, var(--sand) 58% 64%, transparent 64%), radial-gradient(circle at 75% 28%, var(--white) 0 8%, transparent 8.5%), linear-gradient(180deg, var(--sky) 0 58%, var(--accent) 64%, var(--accent-text)); }
.figure[data-art="1"] .art { background: radial-gradient(circle at 30% 70%, var(--accent-soft) 0 22%, transparent 22.5%), radial-gradient(circle at 74% 34%, var(--sand) 0 14%, transparent 14.5%), linear-gradient(200deg, var(--accent-pale), var(--sky)); }
.figure[data-art="2"] .art { background: radial-gradient(ellipse at 20% 100%, var(--ink) 0 34%, transparent 34.5%), radial-gradient(ellipse at 85% 100%, var(--accent-text) 0 42%, transparent 42.5%), linear-gradient(180deg, var(--sand), var(--accent-pale)); }
.figure[data-art="3"] .art { background: linear-gradient(100deg, transparent 46%, var(--sand) 46% 54%, transparent 54%), linear-gradient(180deg, var(--accent-soft), var(--accent-text)); }
.hint { position: absolute; left: 50%; bottom: 12px; translate: -50% 0; padding: 6px 12px; border-radius: 999px; background: var(--ink); color: var(--white); font-size: 12px; font-weight: 600; pointer-events: none; transition: opacity .3s var(--out); }
.scrolled .hint { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.frame { clip-path: inset(0 round 16px); }
.art { scale: 1; }
.figure figcaption { opacity: 1; translate: 0 0; }
}
Use it for
- Editorial galleries and property photo sections
- A few large images that deserve an entrance
Avoid
- Thumbnails and grids of many small images
- Images people need to see immediately, like a product above the fold
How it works
An IntersectionObserver with the scroll container as its root toggles a class when 30 percent of a figure is visible. The class animates clip-path from inset(16% 14% round 16px) to inset(0 round 16px) and the inner art from scale 1.18 to 1, so the frame grows while the picture settles. The caption fades in after a short delay. All work is done by CSS transitions.
Accessibility
Reduced motion shows every image fully revealed with its caption. Captions are real figcaption text. The observer is disconnected on unmount.