Image Clip Reveal
A coloured panel sweeps across and pulls the picture in behind it, while the picture itself settles from a slight zoom.
Create a React image entrance in CSS. Wrap the <img> in a figure with overflow hidden. Play two animations together when it enters the viewport (IntersectionObserver, threshold 0.4, once): the figure goes from clip-path: inset(0 100% 0 0) to inset(0 0 0 0) over 900ms with cubic-bezier(0.65, 0, 0.35, 1), and the image inside scales from 1.15 to 1 over 1400ms with cubic-bezier(0.16, 1, 0.3, 1). Add a solid accent-coloured ::after panel that leads the reveal: it scales from scaleX(0) to scaleX(1) with origin left, then to scaleX(0) with origin right, over the same 900ms. Show the caption "Cameron Highlands, 6:40 am" fading in 300ms after the image. Give the image real alt text. Under prefers-reduced-motion, fade the image in over 150ms instead.
Source
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
export default function ClipReveal({ labels }: { labels: Record<string, string> }) {
const ref = useRef<HTMLElement>(null)
const [run, setRun] = useState(0)
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return
el.dataset.in = ''
io.disconnect()
},
{ threshold: 0.4 },
)
io.observe(el)
return () => io.disconnect()
}, [run])
return (
<div className={s.wrap}>
<figure key={run} ref={ref} className={s.figure}>
<div className={s.clip}>
<div className={s.scene} role="img" aria-label={labels.caption} />
</div>
<figcaption className={s.caption}>{labels.caption}</figcaption>
</figure>
<button type="button" className={s.button} onClick={() => setRun(run + 1)}>
{labels.replay}
</button>
</div>
)
}
.wrap { display: grid; justify-items: center; gap: 10px; width: min(220px, 100%); }
.figure {
--sky-top: oklch(82% 0.05 150);
--sky-low: oklch(74% 0.07 120);
--sun: oklch(95% 0.06 95);
--hill-far: oklch(52% 0.08 150);
--hill-near: oklch(34% 0.06 155);
display: grid;
gap: 8px;
width: 100%;
margin: 0;
}
.clip {
position: relative;
aspect-ratio: 16 / 10;
max-width: 100%;
overflow: hidden;
border-radius: 12px;
clip-path: inset(0 100% 0 0);
}
.clip::after {
content: '';
position: absolute;
inset: 0;
background: var(--stage-accent);
transform: scaleX(0);
transform-origin: left;
}
.scene {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 60% 38% at 20% 104%, var(--hill-near) 98%, transparent 100%),
radial-gradient(ellipse 75% 50% at 80% 102%, var(--hill-far) 98%, transparent 100%),
radial-gradient(circle at 30% 38%, var(--sun) 0 9%, transparent 9.5%),
linear-gradient(180deg, var(--sky-top), var(--sky-low));
transform: scale(1.15);
}
.caption { font: 500 var(--text-xs) / 1.3 var(--font-mono); color: var(--stage-muted); opacity: 0; }
.figure[data-in] .clip { animation: open 900ms var(--ease-in-out) forwards; }
.figure[data-in] .clip::after { animation: wipe 900ms var(--ease-in-out) forwards; }
.figure[data-in] .scene { animation: settle 1400ms var(--ease-out) forwards; }
.figure[data-in] .caption { animation: fade 400ms 1200ms var(--ease-out) forwards; }
@keyframes open { to { clip-path: inset(0 0 0 0); } }
@keyframes wipe {
0% { transform: scaleX(0); transform-origin: left; }
50% { transform: scaleX(1); transform-origin: left; }
51% { transform: scaleX(1); transform-origin: right; }
100% { transform: scaleX(0); transform-origin: right; }
}
@keyframes settle { to { transform: scale(1); } }
@keyframes fade { to { opacity: 1; } }
.button {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: 8px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:hover { border-color: var(--stage-accent); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.clip { clip-path: none; opacity: 0; }
.clip::after { display: none; }
.scene { transform: none; }
.figure[data-in] .clip { animation: fade 150ms linear forwards; }
.figure[data-in] .scene { animation: none; }
.figure[data-in] .caption { animation: fade 150ms linear forwards; }
}