Image Clip Reveal

A coloured panel sweeps across and pulls the picture in behind it, while the picture itself settles from a slight zoom.

  • CSS
  • Images
  • Entrance
  • Added
Cameron Highlands, 6:40 am

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.