03한 번만 vs 매번
같은 진입 애니메이션을 한쪽은 unobserve 로 딱 한 번 재생하고, 다른 쪽은 지날 때마다 다시 켭니다. 애니메이션을 되살리는 재정렬 트릭도 같이 들어 있습니다.
쓰임실적 숫자는 한 번, 안내 강조는 매번
unobserveoffsetWidthdata-once

<main class="stage">
<section class="or is-demo" id="or" aria-label="한 번만과 매번 재생 비교">
<header class="or__head">
<p class="or__title">한 번만 vs 매번</p>
<p class="or__attr"><code>data-once="true"</code></p>
</header>
<div class="or__cols">
<div class="or__col" data-once="true">
<p class="or__label">왼쪽 · 한 번만 재생</p>
<p class="or__plays"><span>재생 횟수</span><span class="or__dots" aria-hidden="true"><i></i><i></i><i></i></span></p>
<div class="or__view" tabindex="0" role="region" aria-label="왼쪽 뷰포트 — 스크롤해 보세요">
<div class="or__track">
<p class="or__lead">실적 카드가 아래에 있습니다</p>
<article class="or__card or__card--stat">
<b>248</b>
<span>완료한 프로젝트</span>
</article>
<p class="or__more">계속 내려도 더 없습니다</p>
</div>
</div>
</div>
<div class="or__col" data-once="false">
<p class="or__label">오른쪽 · 지날 때마다 재생</p>
<p class="or__plays"><span>재생 횟수</span><span class="or__dots" aria-hidden="true"><i></i><i></i><i></i></span></p>
<div class="or__view" tabindex="0" role="region" aria-label="오른쪽 뷰포트 — 스크롤해 보세요">
<div class="or__track">
<p class="or__lead">안내 배너가 아래에 있습니다</p>
<article class="or__card or__card--note">
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M240 120a40 40 0 0 1-40 40h-40V80h40a40 40 0 0 1 40 40" opacity=".2"/><path d="M248 120a48.05 48.05 0 0 0-48-48h-39.8c-2.91-.17-53.62-3.74-101.91-44.24A16 16 0 0 0 32 40v160a16 16 0 0 0 26.29 12.25c37.77-31.68 77-40.76 93.71-43.3v31.72a16 16 0 0 0 7.12 13.33l11 7.33A16 16 0 0 0 194.5 212l11.77-44.36A48.07 48.07 0 0 0 248 120M48 199.93V40c42.81 35.91 86.63 45 104 47.24v65.48c-17.35 2.28-61.16 11.35-104 47.21m131 8v.11l-11-7.33V168h21.6ZM200 152h-32V88h32a32 32 0 1 1 0 64"/></g></svg>
<div class="or__notetext">
<b>새 소식</b>
<span>이번 주 출시 안내</span>
</div>
</article>
<p class="or__more">계속 내려도 더 없습니다</p>
</div>
</div>
</div>
</div>
<footer class="or__foot">왼쪽은 한 번만, 오른쪽은 매번 다시 켭니다</footer>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 한 번만 vs 매번 — data-once 로 unobserve 여부를 정하고, 끝난 등장 애니메이션은 리플로 트릭으로 되살린다
// 미리보기(.is-demo) 는 트랙을 세 번 오르내리며: 왼쪽 카드는 첫 패스에만 등장하고 오른쪽 카드는 매번 다시 등장한다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.or {
box-sizing: border-box;
width: min(430px, 100%);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
box-shadow: $shadow-raised;
}
.or__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.or__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.or__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba($subject-ink, .72);
code { font-family: "Consolas", "Menlo", monospace; }
}
.or__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-3;
padding: 0 $sp-3 $sp-2;
}
.or__col {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas: "label plays" "view view";
gap: $sp-1 $sp-2;
}
.or__label {
grid-area: label;
margin: 0;
font-size: 11px;
font-weight: 800;
line-height: 1.3;
}
.or__plays {
grid-area: plays;
display: flex;
align-items: center;
gap: $sp-1;
margin: 0;
font-size: 9px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
.or__dots {
display: inline-flex;
gap: $sp-1;
}
.or__dots i {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba($subject-ink, .18);
}
.or__dots i.is-lit { background: $color; }
.or__view {
grid-area: view;
box-sizing: border-box;
height: 124px;
overflow-y: auto;
padding: 0 $sp-2;
outline: none;
background: rgba($subject-ink, .05);
border: 1px solid rgba($subject-ink, .14);
border-radius: $r-sm;
}
.or__track {
position: relative; // 안내 문구를 절대 위치로 띄워 줄이 바뀌어도 카드 위치가 흔들리지 않게 한다
padding-top: $sp-2;
padding-bottom: $sp-6; // 스크롤 여유 — 카드를 완전히 담은 채로 멈출 수 있게
}
.or__lead {
position: absolute;
top: $sp-2;
left: $sp-2;
right: $sp-2;
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 14px;
color: rgba($subject-ink, .72);
}
.or__card {
margin-top: 116px; // 카드 top 이 뷰포트 바닥(124px = 8 + 116) 에 딱 닿게 — 진입 전 보이지 않는다
box-sizing: border-box;
height: 76px;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-control;
transform: translateY(8px) scale(.94); // 등장 오프셋 8px — 두 칸이 어긋난 순간에도 카드가 뷰포트 안에 온전히 담긴다
transition: transform $dur-enter $easing;
}
.or__card.is-in { transform: none; }
.or__card--stat {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: $sp-1;
background: $subject-blue-deep;
color: $subject-cream;
b { font-size: 22px; font-weight: 800; line-height: 1; }
span { font-size: 10px; font-weight: 700; line-height: 1.3; }
}
.or__card--note {
background: $stage-orange;
color: $subject-ink;
svg { width: 24px; height: 24px; flex: 0 0 auto; }
b { display: block; font-size: 13px; font-weight: 800; line-height: 1.3; }
span { display: block; font-size: 10px; font-weight: 700; line-height: 1.3; }
}
.or__more {
margin: $sp-2 0 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
.or__foot {
padding: $sp-2 $sp-3;
border-top: 1px solid rgba($subject-ink, .12);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
// 미리보기 — 세 번의 패스(6~16 · 34~44 · 62~72%) 동안 두 칸이 같이 오르내린다
// 카드 상태 교체는 steps(1, end) 컷, 등장 상승은 이징 — 키프레임 안에서 구간마다 정한다
.or.is-demo {
.or__track {
animation-name: or-flow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.or__col[data-once="true"] .or__card {
animation-name: or-in1;
animation-duration: $duration;
animation-iteration-count: infinite;
}
.or__col[data-once="false"] .or__card {
animation-name: or-in2;
animation-duration: $duration;
animation-iteration-count: infinite;
}
.or__col[data-once="true"] .or__dots i:nth-child(1) {
animation-name: or-d1;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.or__col[data-once="false"] .or__dots i:nth-child(1) {
animation-name: or-d1;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.or__col[data-once="false"] .or__dots i:nth-child(2) {
animation-name: or-d2;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.or__col[data-once="false"] .or__dots i:nth-child(3) {
animation-name: or-d3;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@keyframes or-flow {
0%, 6% { transform: translateY(0); }
16%, 20% { transform: translateY(-88px); } // 패스 1 — 88px 이면 등장 오프셋(8px) 이 남아 있는 칸도 안 잘린다
30%, 34% { transform: translateY(0); }
44%, 48% { transform: translateY(-88px); } // 패스 2
58%, 62% { transform: translateY(0); }
72%, 100% { transform: translateY(-88px); } // 패스 3 — 도달 상태로 끝까지, 재시작은 0% 점프
}
// 왼쪽(한 번만) — 첫 패스에 등장한 뒤 루프 끝까지 유지 (unobserve 의 미리보기판)
@keyframes or-in1 {
0%, 10% { transform: translateY(8px) scale(.94); animation-timing-function: $easing; }
18%, 100% { transform: none; }
}
// 오른쪽(매번) — 패스마다 등장하고, 완전히 빠져나간 순간(30 · 58%) 컷으로 초기화
@keyframes or-in2 {
0%, 10% { transform: translateY(8px) scale(.94); animation-timing-function: $easing; }
18% { transform: none; animation-timing-function: steps(1, end); }
30%, 38% { transform: translateY(8px) scale(.94); animation-timing-function: $easing; }
46% { transform: none; animation-timing-function: steps(1, end); }
58%, 66% { transform: translateY(8px) scale(.94); animation-timing-function: $easing; }
74%, 100% { transform: none; }
}
@keyframes or-d1 {
0% { background: rgba($subject-ink, .18); }
10%, 100% { background: $color; }
}
@keyframes or-d2 {
0% { background: rgba($subject-ink, .18); }
38%, 100% { background: $color; }
}
@keyframes or-d3 {
0% { background: rgba($subject-ink, .18); }
66%, 100% { background: $color; }
}
@media (max-width: 340px) {
.or__head { padding: $sp-1 $sp-2; }
.or__cols { gap: $sp-2; padding: 0 $sp-2 $sp-1; }
.or__col { grid-template-columns: 1fr; grid-template-areas: "label" "plays" "view"; }
.or__view { height: 84px; }
.or__card { margin-top: 84px; height: 68px; padding: $sp-2; } // 84 = 뷰포트 높이(84) — 88px 올렸을 때 4px 여유로 카드가 딱 담긴다
.or__card--stat b { font-size: 18px; }
.or__card--note svg { width: 20px; height: 20px; }
.or__foot { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.or__card { transition: none !important; transform: none !important; } // 정지 상태 — 카드가 그대로 보인다
.or.is-demo .or__track,
.or.is-demo .or__card,
.or.is-demo .or__dots i { animation: none !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);
}
.or {
box-sizing: border-box;
width: min(430px, 100%);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.or__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.or__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.or__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.or__attr code {
font-family: "Consolas", "Menlo", monospace;
}
.or__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 0 12px 8px;
}
.or__col {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas: "label plays" "view view";
gap: 4px 8px;
}
.or__label {
grid-area: label;
margin: 0;
font-size: 11px;
font-weight: 800;
line-height: 1.3;
}
.or__plays {
grid-area: plays;
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-size: 9px;
font-weight: 700;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.or__dots {
display: inline-flex;
gap: 4px;
}
.or__dots i {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.18);
}
.or__dots i.is-lit {
background: #2f6df6;
}
.or__view {
grid-area: view;
box-sizing: border-box;
height: 124px;
overflow-y: auto;
padding: 0 8px;
outline: none;
background: rgba(23, 20, 26, 0.05);
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
}
.or__track {
position: relative;
padding-top: 8px;
padding-bottom: 24px;
}
.or__lead {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 14px;
color: rgba(23, 20, 26, 0.72);
}
.or__card {
margin-top: 116px;
box-sizing: border-box;
height: 76px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
transform: translateY(8px) scale(0.94);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.or__card.is-in {
transform: none;
}
.or__card--stat {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
background: #0a4fe8;
color: #fff7e6;
}
.or__card--stat b {
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.or__card--stat span {
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.or__card--note {
background: #ff4d1f;
color: #17141a;
}
.or__card--note svg {
width: 24px;
height: 24px;
flex: 0 0 auto;
}
.or__card--note b {
display: block;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.or__card--note span {
display: block;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.or__more {
margin: 8px 0 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.or__foot {
padding: 8px 12px;
border-top: 1px solid rgba(23, 20, 26, 0.12);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.or.is-demo .or__track {
animation-name: or-flow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.or.is-demo .or__col[data-once=true] .or__card {
animation-name: or-in1;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.or.is-demo .or__col[data-once=false] .or__card {
animation-name: or-in2;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.or.is-demo .or__col[data-once=true] .or__dots i:nth-child(1) {
animation-name: or-d1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.or.is-demo .or__col[data-once=false] .or__dots i:nth-child(1) {
animation-name: or-d1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.or.is-demo .or__col[data-once=false] .or__dots i:nth-child(2) {
animation-name: or-d2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.or.is-demo .or__col[data-once=false] .or__dots i:nth-child(3) {
animation-name: or-d3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes or-flow {
0%, 6% {
transform: translateY(0);
}
16%, 20% {
transform: translateY(-88px);
}
30%, 34% {
transform: translateY(0);
}
44%, 48% {
transform: translateY(-88px);
}
58%, 62% {
transform: translateY(0);
}
72%, 100% {
transform: translateY(-88px);
}
}
@keyframes or-in1 {
0%, 10% {
transform: translateY(8px) scale(0.94);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
18%, 100% {
transform: none;
}
}
@keyframes or-in2 {
0%, 10% {
transform: translateY(8px) scale(0.94);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
18% {
transform: none;
animation-timing-function: steps(1, end);
}
30%, 38% {
transform: translateY(8px) scale(0.94);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
46% {
transform: none;
animation-timing-function: steps(1, end);
}
58%, 66% {
transform: translateY(8px) scale(0.94);
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
74%, 100% {
transform: none;
}
}
@keyframes or-d1 {
0% {
background: rgba(23, 20, 26, 0.18);
}
10%, 100% {
background: #2f6df6;
}
}
@keyframes or-d2 {
0% {
background: rgba(23, 20, 26, 0.18);
}
38%, 100% {
background: #2f6df6;
}
}
@keyframes or-d3 {
0% {
background: rgba(23, 20, 26, 0.18);
}
66%, 100% {
background: #2f6df6;
}
}
@media (max-width: 340px) {
.or__head {
padding: 4px 8px;
}
.or__cols {
gap: 8px;
padding: 0 8px 4px;
}
.or__col {
grid-template-columns: 1fr;
grid-template-areas: "label" "plays" "view";
}
.or__view {
height: 84px;
}
.or__card {
margin-top: 84px;
height: 68px;
padding: 8px;
}
.or__card--stat b {
font-size: 18px;
}
.or__card--note svg {
width: 20px;
height: 20px;
}
.or__foot {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.or__card {
transition: none !important;
transform: none !important;
}
.or.is-demo .or__track,
.or.is-demo .or__card,
.or.is-demo .or__dots i {
animation: none !important;
}
}// 한 번만 vs 매번 — data-once 속성이 unobserve 여부를 결정한다
// 오른쪽처럼 반복하려면 끝난 등장 애니메이션을 리플로 트릭으로 되살려야 한다
var root = document.getElementById('or');
var cols = Array.prototype.slice.call(document.querySelectorAll('.or__col'));
var started = false;
function start() {
if (started) return;
started = true;
root.classList.remove('is-demo');
cols.forEach(setup);
}
function setup(col) {
var once = col.getAttribute('data-once') === 'true';
var view = col.querySelector('.or__view');
var card = col.querySelector('.or__card');
var dots = Array.prototype.slice.call(col.querySelectorAll('.or__dots i'));
var plays = 0;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
card.classList.remove('is-in');
void card.offsetWidth; // 리플로 트릭 — 여기서 강제 재계산이 일어나야 클래스를 다시 붙일 때 애니메이션이 처음부터 돈다
card.classList.add('is-in');
plays += 1;
dots.forEach(function (d, i) { d.classList.toggle('is-lit', i < Math.min(plays, 3)); });
if (once) io.unobserve(card); // 왼쪽 — 딱 한 번 재생하고 관찰을 멈춘다
} else if (!once) {
card.classList.remove('is-in'); // 오른쪽 — 벗어나면 초기화, 다음 진입 때 다시 재생된다
}
});
}, { root: view, threshold: 0.5 });
io.observe(card);
}
cols.forEach(function (col) {
var view = col.querySelector('.or__view');
view.addEventListener('scroll', start, { passive: true });
view.addEventListener('pointerdown', start);
view.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') start();
});
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./OnceRepeat.module.scss";
export type OnceRepeatProps = {
title?: string;
attr?: string;
foot?: string;
groupLabel?: string;
playsLabel?: string;
cols?: {
once: boolean;
label: string;
viewLabel: string;
lead: string;
more: string;
}[];
statValue?: string;
statLabel?: string;
newsTitle?: string;
newsSub?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type Box<T> = { current: T };
type KeyEvt = { key: string };
const DEFAULT_COLS = [
{
once: true,
label: "왼쪽 · 한 번만 재생",
viewLabel: "왼쪽 뷰포트 — 스크롤해 보세요",
lead: "실적 카드가 아래에 있습니다",
more: "계속 내려도 더 없습니다",
},
{
once: false,
label: "오른쪽 · 지날 때마다 재생",
viewLabel: "오른쪽 뷰포트 — 스크롤해 보세요",
lead: "안내 배너가 아래에 있습니다",
more: "계속 내려도 더 없습니다",
},
];
export default function OnceRepeat({
title = "한 번만 vs 매번",
attr = 'data-once="true"',
foot = "왼쪽은 한 번만, 오른쪽은 매번 다시 켭니다",
groupLabel = "한 번만과 매번 재생 비교",
playsLabel = "재생 횟수",
cols = DEFAULT_COLS,
statValue = "248",
statLabel = "완료한 프로젝트",
newsTitle = "새 소식",
newsSub = "이번 주 출시 안내",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: OnceRepeatProps) {
const [live, setLive] = useState(demo);
const [plays, setPlays] = useState<number[]>(cols.map(() => 0));
const viewRefs = useRef<(HTMLDivElement | null)[]>([]) as unknown as Box<(HTMLDivElement | null)[]>;
const cardRefs = useRef<(HTMLElement | null)[]>([]) as unknown as Box<(HTMLElement | null)[]>;
useEffect(() => {
if (live) return;
const ios = cols.map((c, i) => {
const view = viewRefs.current[i];
const card = cardRefs.current[i];
if (!view || !card) return null;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
card.classList.remove(styles.isIn);
void card.offsetWidth; // 리플로 트릭 — 여기서 강제 재계산이 일어나야 클래스를 다시 붙일 때 등장이 처음부터 돈다
card.classList.add(styles.isIn);
setPlays((prev) => {
const next = prev.slice();
next[i] = next[i] + 1;
return next;
});
if (c.once) io.unobserve(card); // 왼쪽 — 딱 한 번 재생하고 관찰을 멈춘다
} else if (!c.once) {
card.classList.remove(styles.isIn); // 오른쪽 — 벗어나면 초기화, 다음 진입 때 다시 재생된다
}
});
},
{ root: view, threshold: 0.5 }
);
io.observe(card);
return io;
});
return () => {
ios.forEach((io) => io && io.disconnect());
};
// cols 는 모듈 상수(또는 첫 렌더 이후 그대로인 props) — live 전환 때만 다시 붙는다
}, [live]);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<section
className={`${styles.or} ${live ? styles.isDemo : ""}`}
style={vars}
aria-label={groupLabel}
>
<header className={styles.head}>
<p className={styles.title}>{title}</p>
<p className={styles.attr}><code>{attr}</code></p>
</header>
<div className={styles.cols}>
{cols.map((c, i) => (
<div className={styles.col} key={c.once ? "once" : "repeat"} data-once={c.once ? "true" : "false"}>
<p className={styles.label}>{c.label}</p>
<p className={styles.plays}>
<span>{playsLabel}</span>
<span className={styles.dots} aria-hidden="true">
{[0, 1, 2].map((d) => (
<i key={d} className={d < Math.min(plays[i], 3) ? styles.isLit : undefined} />
))}
</span>
</p>
<div
className={styles.view}
ref={(el: HTMLDivElement | null) => {
viewRefs.current[i] = el;
}}
onScroll={() => setLive(false)}
onPointerDown={() => setLive(false)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") setLive(false);
}}
tabIndex={0}
role="region"
aria-label={c.viewLabel}
>
<div className={styles.track}>
<p className={styles.lead}>{c.lead}</p>
{c.once ? (
<article
ref={(el: HTMLElement | null) => {
cardRefs.current[i] = el;
}}
className={`${styles.card} ${styles.cardStat}`}
>
<b>{statValue}</b>
<span>{statLabel}</span>
</article>
) : (
<article
ref={(el: HTMLElement | null) => {
cardRefs.current[i] = el;
}}
className={`${styles.card} ${styles.cardNote}`}
>
<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false"><g fill="currentColor"><path d="M240 120a40 40 0 0 1-40 40h-40V80h40a40 40 0 0 1 40 40" opacity=".2"/><path d="M248 120a48.05 48.05 0 0 0-48-48h-39.8c-2.91-.17-53.62-3.74-101.91-44.24A16 16 0 0 0 32 40v160a16 16 0 0 0 26.29 12.25c37.77-31.68 77-40.76 93.71-43.3v31.72a16 16 0 0 0 7.12 13.33l11 7.33A16 16 0 0 0 194.5 212l11.77-44.36A48.07 48.07 0 0 0 248 120M48 199.93V40c42.81 35.91 86.63 45 104 47.24v65.48c-17.35 2.28-61.16 11.35-104 47.21m131 8v.11l-11-7.33V168h21.6ZM200 152h-32V88h32a32 32 0 1 1 0 64"/></g></svg>
<div className={styles.noteText}>
<b>{newsTitle}</b>
<span>{newsSub}</span>
</div>
</article>
)}
<p className={styles.more}>{c.more}</p>
</div>
</div>
</div>
))}
</div>
<footer className={styles.foot}>{foot}</footer>
</section>
);
}03 한 번만 vs 매번 — 측정 누적면적 23.665% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로
이 데모가 속한 글
스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로스크롤 트리거 애니메이션이란 화면을 내리다가 요소가 뷰포트에 들어오는 순간 움직임을 시작하게 하는 장치입니다. GSAP 없이 IntersectionObserver 하나로 아홉 개의 엔진을 직접 만들었고, 아래 격자에 이 글의 데모 전부 보기


