<section class="demo">
<div class="demo__inner">
<p class="demo__eyebrow">Interaction 007</p>
<h1 class="demo__title" data-lines>
<span>Motion is not</span>
<span>decoration —</span>
<span>it is meaning.</span>
</h1>
<p class="demo__meta">Designbase · Editorial reveal</p>
</div>
</section>.demo {
width: 100%;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 48px 32px;
background: #08080a;
color: #f4f4f2;
font-family: "Helvetica Neue", Inter, system-ui, sans-serif;
}
.demo__inner {
width: min(640px, 100%);
}
.demo__eyebrow,
.demo__meta {
margin: 0;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6b6b70;
}
.demo__title {
margin: 20px 0;
font-size: clamp(30px, 6.4vw, 58px);
font-weight: 500;
line-height: 1.06;
letter-spacing: -0.035em;
}
/* 각 줄을 마스크 박스로 감쌉니다 */
.demo__title span {
display: block;
overflow: hidden;
}
.demo__title span > i {
display: block;
font-style: normal;
will-change: transform;
}gsap.registerPlugin(ScrollTrigger);
// 마크업을 건드리지 않고 줄마다 내부 래퍼를 만들어 마스크를 만듭니다.
const lines = gsap.utils.toArray(".demo__title span");
lines.forEach((line) => {
line.innerHTML = "<i>" + line.textContent.trim() + "</i>";
});
const inner = lines.map((line) => line.firstElementChild);
gsap.set(inner, { yPercent: 118 });
gsap.set([".demo__eyebrow", ".demo__meta"], { opacity: 0, y: 12 });
const tl = gsap.timeline({
defaults: { ease: "expo.out" },
scrollTrigger: { trigger: ".demo__inner", start: "top 88%" },
});
tl.to(".demo__eyebrow", { opacity: 1, y: 0, duration: 0.7 })
.to(inner, { yPercent: 0, duration: 1.15, stagger: 0.09 }, "-=0.45")
.to(".demo__meta", { opacity: 1, y: 0, duration: 0.7 }, "-=0.6");