08스크롤 마스크 리빌
사진이 화면에 들어오면 IntersectionObserver가 켠 클래스에 맞춰 mask-image가 위치를 쓸며 지나가, 딱딱한 경계 없이 부드러운 가장자리로 드러납니다.
쓰임랜딩 페이지 히어로 사진
mask-imagemask-positionJS 7줄

<main class="stage">
<div class="mk-frame is-demo">
<div class="mk-photo"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.mk-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8 / 5;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.mk-photo {
position: absolute;
inset: 0;
background:
radial-gradient(110px circle at 34% 32%, rgba(255, 255, 255, .55), transparent 60%),
linear-gradient(150deg, $color, $subject-ink);
mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
-webkit-mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
mask-size: 260% 100%;
-webkit-mask-size: 260% 100%;
// 기본(트리거 전) = 숨김. mask-size 가 260% 라 position 0% 는 그라디언트의 transparent(0~38%) 구간이 창 전체를 덮는다
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
// 실사용: 진짜 IntersectionObserver 가 뷰포트에 들어올 때 .in 을 붙인다(위 <script>) — mask-position 이 오른쪽에서 왼쪽으로 쓸고 지나가며 부드러운 가장자리로 드러난다
.mk-photo { transition: mask-position 700ms $easing, -webkit-mask-position 700ms $easing; }
.mk-frame.in .mk-photo { mask-position: 100% 0; -webkit-mask-position: 100% 0; } // position 100% 는 black(58~100%) 구간이 창 전체를 덮어 완전히 드러난다
// 데모 루프: is-demo 는 같은 마스크 스윕을 keyframes 로 무한 재생한다(캡처용, 실제 스크롤 없이도 재현)
@keyframes mk-sweep {
0%, 8% { mask-position: 0 0; -webkit-mask-position: 0 0; }
45%, 60% { mask-position: 100% 0; -webkit-mask-position: 100% 0; }
95%, 100% { mask-position: 0 0; -webkit-mask-position: 0 0; }
}
.mk-frame.is-demo .mk-photo { animation: mk-sweep $duration $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.mk-frame.in .mk-photo,
.mk-frame.is-demo .mk-photo {
animation: none !important;
transition: none !important;
mask-position: 100% 0 !important;
-webkit-mask-position: 100% 0 !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mk-frame {
position: relative;
width: min(260px, 78vw);
aspect-ratio: 8/5;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mk-photo {
position: absolute;
inset: 0;
background: radial-gradient(110px circle at 34% 32%, rgba(255, 255, 255, 0.55), transparent 60%), linear-gradient(150deg, #2f6df6, #17141a);
mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
-webkit-mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
mask-size: 260% 100%;
-webkit-mask-size: 260% 100%;
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
.mk-photo {
transition: mask-position 700ms ease-out, -webkit-mask-position 700ms ease-out;
}
.mk-frame.in .mk-photo {
mask-position: 100% 0;
-webkit-mask-position: 100% 0;
}
@keyframes mk-sweep {
0%, 8% {
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
45%, 60% {
mask-position: 100% 0;
-webkit-mask-position: 100% 0;
}
95%, 100% {
mask-position: 0 0;
-webkit-mask-position: 0 0;
}
}
.mk-frame.is-demo .mk-photo {
animation: mk-sweep 2s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.mk-frame.in .mk-photo,
.mk-frame.is-demo .mk-photo {
animation: none !important;
transition: none !important;
mask-position: 100% 0 !important;
-webkit-mask-position: 100% 0 !important;
}
}var frame = document.querySelector('.mk-frame');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.3 });
io.observe(frame);import { CSSProperties, useEffect, useRef, useState } from "react";
import styles from "./ScrollRevealMask.module.scss";
export interface ScrollRevealMaskProps {
duration?: string;
easing?: string;
color?: string;
}
export default function ScrollRevealMask({
duration = "700ms",
easing = "ease-out",
color = "#2f6df6",
}: ScrollRevealMaskProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => entries.forEach((e) => e.isIntersecting && setInView(true)),
{ threshold: 0.3 }
);
io.observe(el);
return () => io.disconnect();
}, []);
const style: CSSProperties = {
transitionDuration: duration,
transitionTimingFunction: easing,
maskPosition: inView ? "100% 0" : "0 0",
WebkitMaskPosition: inView ? "100% 0" : "0 0",
};
return (
<div ref={ref} className={styles.frame}>
<div className={styles.photo} style={{ ...style, background: `linear-gradient(150deg, ${color}, #17141a)` }} />
</div>
);
}08 스크롤 마스크 리빌 — 측정 누적면적 29.122% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 이미지 슬라이드 애니메이션 9종
이 데모가 속한 글
CSS 이미지 슬라이드 애니메이션 9종사진 위에 마우스를 올리면 커튼처럼 열리거나 원이 점점 커지며 드러나는 걸 본 적 있으신가요? 이런 CSS 이미지 슬라이드 애니메이션을 한 줄 복붙으로 쓸 수 있게 아홉 가지로 모았고, 그중 하나는 자바스크립트 없이 이 글의 데모 전부 보기


