02Thumbnail Revealed as a Boundary Value Moves
While the range value fills, the boundary value of the hidden area travels from one edge to the other, uncovering the image little by little.
Best forPortfolio project thumbnail reveal
clip-path: insetanimation-range: entrycprReveal

<main class="stage">
<div class="cpr is-demo" id="cprRoot">
<div class="cpr__badge" id="cprBadge">entry range 0%~0%</div>
<button class="cpr__ctrl" id="cprCtrl" type="button">Pause</button>
<div class="cpr__view" id="cprView">
<div class="cpr__track" id="cprTrack">
<div class="cpr__spacer cpr__spacer--top"></div>
<div class="cpr__panel" id="cprPanel">
<span class="cpr__label">Project</span>
</div>
<div class="cpr__spacer cpr__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: entry 로 clip-path 의 가려진
// 폭을 진입 구간 값(0~1)에 직접 묶어 커튼처럼 걷어낸다. 미지원 환경은 교차 비율을
// 같은 공식으로 계산해 동일한 inset() 값을 적용한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.cpr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.cpr__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.cpr__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.cpr__ctrl:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cpr__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .06);
scrollbar-width: thin;
}
.cpr__track {
display: flex;
flex-direction: column;
align-items: center;
}
.cpr__spacer--top { height: 190px; flex: 0 0 auto; }
.cpr__spacer--bottom { height: 40px; flex: 0 0 auto; }
.cpr__panel {
flex: 0 0 auto;
box-sizing: border-box;
width: 216px;
height: 76px;
display: grid;
place-items: center;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
clip-path: inset(0 100% 0 0);
}
.cpr__label {
color: $subject-cream;
font-weight: 700;
font-size: 14px;
}
.supports-vt .cpr__panel {
animation-name: cprReveal;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cprReveal {
0% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 0% 0 0); }
}
@media (max-width: 340px) {
.cpr { gap: $sp-2; }
.cpr__view { height: 130px; }
.cpr__spacer--top { height: 130px; }
.cpr__spacer--bottom { height: 26px; }
.cpr__panel { width: 176px; height: 60px; }
}
@media (prefers-reduced-motion: reduce) {
.cpr__panel { animation: none !important; clip-path: inset(0 0% 0 0) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cpr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.cpr__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.cpr__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.cpr__ctrl:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cpr__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
scrollbar-width: thin;
}
.cpr__track {
display: flex;
flex-direction: column;
align-items: center;
}
.cpr__spacer--top {
height: 190px;
flex: 0 0 auto;
}
.cpr__spacer--bottom {
height: 40px;
flex: 0 0 auto;
}
.cpr__panel {
flex: 0 0 auto;
box-sizing: border-box;
width: 216px;
height: 76px;
display: grid;
place-items: center;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
clip-path: inset(0 100% 0 0);
}
.cpr__label {
color: #fff7e6;
font-weight: 700;
font-size: 14px;
}
.supports-vt .cpr__panel {
animation-name: cprReveal;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cprReveal {
0% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 0% 0 0);
}
}
@media (max-width: 340px) {
.cpr {
gap: 8px;
}
.cpr__view {
height: 130px;
}
.cpr__spacer--top {
height: 130px;
}
.cpr__spacer--bottom {
height: 26px;
}
.cpr__panel {
width: 176px;
height: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
.cpr__panel {
animation: none !important;
clip-path: inset(0 0% 0 0) !important;
}
}(function () {
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('cprRoot');
var view = document.getElementById('cprView');
var panel = document.getElementById('cprPanel');
var badge = document.getElementById('cprBadge');
var ctrl = document.getElementById('cprCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소 높이·뷰포트 높이에서 진입 구간(시작~끝 %) 값을 구해 배지에 표시한다.
function entryRangePct() {
var elH = panel.offsetHeight;
var viewH = view.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
var rangeEnd = entryRangePct();
var rangeText = 'entry range 0%~' + rangeEnd + '%';
function render(ratio) {
if (!supportsVt) {
var hide = (1 - ratio) * 100;
panel.style.clipPath = 'inset(0 ' + hide.toFixed(1) + '% 0 0)';
}
badge.textContent = rangeText + ' · progress ' + Math.round(ratio * 100) + '%';
}
var lastRatio = 0;
if (!supportsVt) {
var STEPS = [];
for (var s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
render(lastRatio);
});
}, { root: view, threshold: STEPS });
io.observe(panel);
} else {
badge.textContent = rangeText;
}
view.addEventListener('scroll', function () {
if (supportsVt) {
var max = view.scrollHeight - view.clientHeight;
var p = max > 0 ? Math.min(1, Math.max(0, view.scrollTop / max)) : 0;
badge.textContent = rangeText + ' · scroll ' + Math.round(p * 100) + '%';
}
}, { passive: true });
// 자동 시연: rAF 로 프레임을 누적해 컨테이너를 왕복 스크롤한다.
var playing = true;
var frame = 0;
var PERIOD = 66;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = 'Play';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? 'Pause' : 'Play';
if (playing) { root.classList.add('is-demo'); }
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
lastRatio = 0;
render(0);
playing = true;
root.classList.add('is-demo');
ctrl.textContent = 'Pause';
}
});
window.addEventListener('resize', function () {
rangeEnd = entryRangePct();
rangeText = 'entry range 0%~' + rangeEnd + '%';
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
lastRatio = 1;
render(1);
panel.style.clipPath = 'inset(0 0% 0 0)';
badge.textContent = rangeText + ' · final state';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./ClipPathReveal.module.scss";
// 핵심: animation-timeline: view() + animation-range: entry 로 clip-path 의 가려진
// 폭을 진입 구간 값(0~1)에 직접 묶어 커튼처럼 걷어낸다. 미지원 환경은 교차 비율로
// 같은 공식을 계산해 동일한 inset() 값을 적용한다.
export default function ClipPathReveal() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("진입 구간 0%~0%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const panel = panelRef.current;
const root = rootRef.current;
if (!view || !panel || !root) { return; }
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function entryRangePct() {
const elH = panel!.offsetHeight;
const viewH = view!.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
let rangeEnd = entryRangePct();
let rangeText = "진입 구간 0%~" + rangeEnd + "%";
function render(ratio: number) {
if (!vt) {
const hide = (1 - ratio) * 100;
panel!.style.clipPath = "inset(0 " + hide.toFixed(1) + "% 0 0)";
}
setBadge(rangeText + " · 진행 " + Math.round(ratio * 100) + "%");
}
let io: IntersectionObserver | null = null;
if (!vt) {
const STEPS: number[] = [];
for (let s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { render(entry.intersectionRatio); });
}, { root: view, threshold: STEPS });
io.observe(panel);
} else {
setBadge(rangeText);
}
function onScroll() {
if (vt) {
const max = view!.scrollHeight - view!.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, view!.scrollTop / max)) : 0;
setBadge(rangeText + " · 스크롤 " + Math.round(p * 100) + "%");
}
}
view.addEventListener("scroll", onScroll, { passive: true });
let playing = true;
let frame = 0;
const PERIOD = 66;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
render(0);
playing = true;
setDemo(true);
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() {
rangeEnd = entryRangePct();
rangeText = "진입 구간 0%~" + rangeEnd + "%";
}
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
panel.style.clipPath = "inset(0 0% 0 0)";
setBadge(rangeText + " · 최종 상태");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
io?.disconnect();
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.cpr} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["cpr__badge"]}>{badge}</div>
<button className={styles["cpr__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["cpr__view"]}>
<div className={styles["cpr__track"]}>
<div className={`${styles["cpr__spacer"]} ${styles["cpr__spacer--top"]}`} />
<div ref={panelRef} className={styles["cpr__panel"]}>
<span className={styles["cpr__label"]}>프로젝트</span>
</div>
<div className={`${styles["cpr__spacer"]} ${styles["cpr__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}02 Thumbnail Revealed as a Boundary Value Moves — Measured area 17.112% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Scroll Animation: 9 View-Timeline Examples
The post this demo lives in
CSS Scroll Animation: 9 View-Timeline ExamplesNine css scroll animation demos, one idea: A card sits blurred, then sharpens as you scroll past it — one range value dr See all demos in this post


