06방향 감지 진입·퇴장
직전 스크롤 위치와 비교해 위로 가는지 아래로 가는지를 알아내고, 들어올 때와 나갈 때 다른 모습을 씁니다. isIntersecting 이 바뀌는 순간마다 방향 값이 갱신됩니다.
쓰임작품 목록 카드 흐름
isIntersectingscrollTopis-exit

<main class="stage">
<section class="dt is-demo" id="dt" aria-label="방향 감지 진입·퇴장 데모">
<header class="dt__head">
<p class="dt__title">방향 감지 진입·퇴장</p>
<p class="dt__attr"><code>scrollTop</code></p>
</header>
<div class="dt__view" id="dt-view" tabindex="0" role="region" aria-label="카드 목록 — 스크롤 방향에 따라 모습이 달라집니다">
<div class="dt__track">
<p class="dt__hint">아래로 스크롤</p>
<article class="dt__card" id="dt-card">
<b>작품 07</b>
<span>아래로 지나가면 기울며 퇴장</span>
</article>
<p class="dt__filler" aria-hidden="true">다음 섹션</p>
</div>
</div>
<footer class="dt__foot">
<span class="dt__dirBox" aria-hidden="true">
<span class="dt__dir dt__dir--down">
<svg class="dt__ico" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m200 144l-72 72l-72-72Z" opacity=".2"/><path d="M207.39 140.94A8 8 0 0 0 200 136h-64V40a8 8 0 0 0-16 0v96H56a8 8 0 0 0-5.66 13.66l72 72a8 8 0 0 0 11.32 0l72-72a8 8 0 0 0 1.73-8.72M128 204.69L75.31 152h105.38Z"/></g></svg>
<span>아래로 이동</span>
</span>
<span class="dt__dir dt__dir--up">
<svg class="dt__ico" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 112H56l72-72Z" opacity=".2"/><path d="m205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z"/></g></svg>
<span>위로 이동</span>
</span>
</span>
<span class="dt__phase">
<span class="dt__phaseWait">스크롤 대기</span>
<span class="dt__phaseIn">진입 중</span>
<span class="dt__phaseOut">퇴장 감지</span>
</span>
</footer>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 방향 감지 진입·퇴장 — 진입(is-visible) 은 아래에서 떠오르고 퇴장(is-exit) 은 기울며 스치고 나간다
// 방향 값은 isIntersecting 이 바뀌는 순간마다 갱신된다(data-dir). 미리보기(.is-demo)는 아래 방향 한 주기를 재생한다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-light;
@include stage($stage-ink);
.dt {
position: relative;
box-sizing: border-box;
width: min(312px, 92vw);
border-radius: $r-card;
background: $tick-panel;
border: 1px solid rgba($subject-cream, .16);
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
box-shadow: $shadow-raised;
}
.dt__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.dt__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.dt__attr {
margin: 0;
font-size: 10px;
font-weight: 400;
line-height: 1.3;
color: rgba($subject-cream, .75);
code { font-family: "Consolas", "Menlo", monospace; }
}
.dt__view {
height: 168px;
overflow-y: auto;
padding: 0 $sp-3;
outline: none;
}
.dt__track {
display: grid;
gap: $sp-3;
padding-top: $sp-2;
padding-bottom: 120px;
}
.dt__hint {
margin: 0;
box-sizing: border-box;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-cream, .75);
border: 1px dashed rgba($subject-cream, .32);
border-radius: $r-control;
padding: $sp-3 0;
text-align: center;
}
// 카드는 첫 화면 밖에 있다가 방향값을 달고 들어온다 — margin 으로 접힌 아래까지 민다
.dt__card {
box-sizing: border-box;
margin-top: 116px;
display: grid;
gap: $sp-1;
padding: $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
opacity: 0;
transform: translateY(28px);
transition-property: opacity, transform;
transition-duration: $dur-quick, $dur-enter;
transition-timing-function: ease-out, $easing;
b { font-size: 15px; font-weight: 800; line-height: 1.2; color: $subject-blue-deep; }
span { font-size: 11px; font-weight: 700; line-height: 1.3; }
}
// 위로 스크롤해 들어오면 반대편(위)에서 내려온다 — 진입 방향이 모습을 바꾼다
.dt__card[data-dir="up"] { transform: translateY(-28px); }
.dt__card.is-visible {
opacity: 1;
transform: none;
}
// 퇴장 — 나가는 쪽으로 기울며 스친다. 아래로 스크롤 중 벗어나면 위 변으로, 위로 스크롤 중 벗어나면 아래 변으로
.dt__card.is-exit[data-dir="down"] {
opacity: 0;
transform: translateY(-30px) rotate(-5deg) scale(.94);
}
.dt__card.is-exit[data-dir="up"] {
opacity: 0;
transform: translateY(34px) rotate(5deg) scale(.94);
}
.dt__filler {
margin: 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-cream, .75);
border-radius: $r-control;
background: rgba($subject-cream, .08);
padding: $sp-6 0;
text-align: center;
}
.dt__foot {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-top: 1px solid rgba($subject-cream, .16);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
// 방향·단계 표시는 같은 자리에 겹쳐 두고 하나만 보인다 — 세 개를 나란히 두면 폭이 넘쳐 줄이 접힌다
.dt__dirBox {
position: relative;
height: 20px;
flex: 0 0 auto;
}
.dt__dir {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
gap: $sp-1;
white-space: nowrap;
opacity: 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: $subject-blue-light;
}
[data-dir="down"] .dt__dir--down { opacity: 1; }
[data-dir="up"] .dt__dir--up { opacity: 1; }
.dt__ico {
width: 12px;
height: 12px;
}
.dt__phase {
position: relative;
height: 20px;
margin-left: auto;
}
.dt__phaseWait,
.dt__phaseIn,
.dt__phaseOut {
position: absolute;
right: 0;
top: 0;
display: inline-block;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
font-size: 9px;
font-weight: 800;
line-height: 1.3;
white-space: nowrap;
opacity: 0;
}
.dt__phaseWait {
background: rgba($subject-cream, .16);
color: $subject-cream;
opacity: 1;
}
.dt__phaseIn {
background: $subject-blue-light;
color: $subject-ink;
}
.dt__phaseOut {
background: $subject-cream;
color: $subject-ink;
}
.dt.is-enter .dt__phaseWait { opacity: 0; }
.dt.is-enter .dt__phaseIn { opacity: 1; }
.dt.is-leave .dt__phaseWait { opacity: 0; }
.dt.is-leave .dt__phaseOut { opacity: 1; }
// 미리보기 — 아래로 스크롤하는 한 주기: 떠오르는 진입, 머무름, 기울며 퇴장
// 나타남·사라짐은 steps 컷(dt-cut), 이동·기울임은 이징(dt-move) — 페이드 중간 프레임이 유령으로 찍히지 않는다
// 안내 문구는 카드가 들어오는 컷과 같은 순간에 사라진다 — 등록 순간의 변화가 퇴장보다 커야 poster 가 주인공 있는 장면이 된다
.dt.is-demo {
.dt__track {
animation-name: dt-flow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.dt__card {
animation-name: dt-cut, dt-move;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.dt__hint {
animation-name: dt-hint;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dt__phaseWait {
animation-name: dt-p-wait;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dt__phaseIn {
animation-name: dt-p-in;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dt__phaseOut {
animation-name: dt-p-out;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@keyframes dt-flow {
0%, 8% { transform: translateY(0); }
// 퇴장 구간(56% 부터)은 linear 로 길게 — 이징이 한 프레임에 몰리면 그 장이 poster 로 뽑힌다
26%, 56% { transform: translateY(-144px); animation-timing-function: linear; }
80%, 100% { transform: translateY(-264px); }
}
@keyframes dt-cut {
0% { opacity: 0; }
// 카드는 80% 이전에 화면 밖으로 나갔으므로 이 컷은 빈 화면을 바꾼다 (변화 0)
10%, 78% { opacity: 1; }
80%, 100% { opacity: 0; }
}
@keyframes dt-move {
0% { transform: translateY(28px); }
26%, 56% { transform: none; animation-timing-function: linear; }
80%, 100% { transform: translateY(-30px) rotate(-5deg) scale(.94); }
}
@keyframes dt-hint {
0%, 10% { opacity: 1; }
12%, 100% { opacity: 0; }
}
@keyframes dt-p-wait {
0%, 12% { opacity: 1; }
14%, 100% { opacity: 0; }
}
@keyframes dt-p-in {
0%, 12% { opacity: 0; }
14%, 58% { opacity: 1; }
60%, 100% { opacity: 0; }
}
@keyframes dt-p-out {
0%, 60% { opacity: 0; }
62%, 100% { opacity: 1; }
}
@media (max-width: 340px) {
.dt { width: 100%; }
.dt__head { padding: $sp-1 $sp-2; }
.dt__view { height: 108px; }
.dt__hint { padding: $sp-2 0; }
.dt__card { margin-top: 96px; }
.dt__filler { padding: $sp-4 0; }
.dt__foot { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.dt__card { transition: none !important; }
.dt.is-demo .dt__track,
.dt.is-demo .dt__card,
.dt.is-demo .dt__hint,
.dt.is-demo .dt__phaseWait,
.dt.is-demo .dt__phaseIn,
.dt.is-demo .dt__phaseOut { animation: none !important; }
// 정지 상태 — 카드가 화면 안에 그대로 보인다
.dt.is-demo .dt__card { opacity: 1 !important; transform: none !important; }
.dt.is-demo .dt__track { transform: translateY(-144px) !important; }
.dt.is-demo .dt__hint { opacity: 0 !important; }
.dt.is-demo .dt__phaseIn { opacity: 1 !important; }
.dt.is-demo .dt__phaseWait,
.dt.is-demo .dt__phaseOut { opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dt {
position: relative;
box-sizing: border-box;
width: min(312px, 92vw);
border-radius: 16px;
background: #231f28;
border: 1px solid rgba(255, 247, 230, 0.16);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dt__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.dt__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.dt__attr {
margin: 0;
font-size: 10px;
font-weight: 400;
line-height: 1.3;
color: rgba(255, 247, 230, 0.75);
}
.dt__attr code {
font-family: "Consolas", "Menlo", monospace;
}
.dt__view {
height: 168px;
overflow-y: auto;
padding: 0 12px;
outline: none;
}
.dt__track {
display: grid;
gap: 12px;
padding-top: 8px;
padding-bottom: 120px;
}
.dt__hint {
margin: 0;
box-sizing: border-box;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba(255, 247, 230, 0.75);
border: 1px dashed rgba(255, 247, 230, 0.32);
border-radius: 12px;
padding: 12px 0;
text-align: center;
}
.dt__card {
box-sizing: border-box;
margin-top: 116px;
display: grid;
gap: 4px;
padding: 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
opacity: 0;
transform: translateY(28px);
transition-property: opacity, transform;
transition-duration: 220ms, 600ms;
transition-timing-function: ease-out, cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dt__card b {
font-size: 15px;
font-weight: 800;
line-height: 1.2;
color: #0a4fe8;
}
.dt__card span {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.dt__card[data-dir=up] {
transform: translateY(-28px);
}
.dt__card.is-visible {
opacity: 1;
transform: none;
}
.dt__card.is-exit[data-dir=down] {
opacity: 0;
transform: translateY(-30px) rotate(-5deg) scale(0.94);
}
.dt__card.is-exit[data-dir=up] {
opacity: 0;
transform: translateY(34px) rotate(5deg) scale(0.94);
}
.dt__filler {
margin: 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba(255, 247, 230, 0.75);
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
padding: 24px 0;
text-align: center;
}
.dt__foot {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid rgba(255, 247, 230, 0.16);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.dt__dirBox {
position: relative;
height: 20px;
flex: 0 0 auto;
}
.dt__dir {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
opacity: 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: #5b8cf8;
}
[data-dir=down] .dt__dir--down {
opacity: 1;
}
[data-dir=up] .dt__dir--up {
opacity: 1;
}
.dt__ico {
width: 12px;
height: 12px;
}
.dt__phase {
position: relative;
height: 20px;
margin-left: auto;
}
.dt__phaseWait,
.dt__phaseIn,
.dt__phaseOut {
position: absolute;
right: 0;
top: 0;
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-size: 9px;
font-weight: 800;
line-height: 1.3;
white-space: nowrap;
opacity: 0;
}
.dt__phaseWait {
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
opacity: 1;
}
.dt__phaseIn {
background: #5b8cf8;
color: #17141a;
}
.dt__phaseOut {
background: #fff7e6;
color: #17141a;
}
.dt.is-enter .dt__phaseWait {
opacity: 0;
}
.dt.is-enter .dt__phaseIn {
opacity: 1;
}
.dt.is-leave .dt__phaseWait {
opacity: 0;
}
.dt.is-leave .dt__phaseOut {
opacity: 1;
}
.dt.is-demo .dt__track {
animation-name: dt-flow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.dt.is-demo .dt__card {
animation-name: dt-cut, dt-move;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.dt.is-demo .dt__hint {
animation-name: dt-hint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dt.is-demo .dt__phaseWait {
animation-name: dt-p-wait;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dt.is-demo .dt__phaseIn {
animation-name: dt-p-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dt.is-demo .dt__phaseOut {
animation-name: dt-p-out;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dt-flow {
0%, 8% {
transform: translateY(0);
}
26%, 56% {
transform: translateY(-144px);
animation-timing-function: linear;
}
80%, 100% {
transform: translateY(-264px);
}
}
@keyframes dt-cut {
0% {
opacity: 0;
}
10%, 78% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
@keyframes dt-move {
0% {
transform: translateY(28px);
}
26%, 56% {
transform: none;
animation-timing-function: linear;
}
80%, 100% {
transform: translateY(-30px) rotate(-5deg) scale(0.94);
}
}
@keyframes dt-hint {
0%, 10% {
opacity: 1;
}
12%, 100% {
opacity: 0;
}
}
@keyframes dt-p-wait {
0%, 12% {
opacity: 1;
}
14%, 100% {
opacity: 0;
}
}
@keyframes dt-p-in {
0%, 12% {
opacity: 0;
}
14%, 58% {
opacity: 1;
}
60%, 100% {
opacity: 0;
}
}
@keyframes dt-p-out {
0%, 60% {
opacity: 0;
}
62%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.dt {
width: 100%;
}
.dt__head {
padding: 4px 8px;
}
.dt__view {
height: 108px;
}
.dt__hint {
padding: 8px 0;
}
.dt__card {
margin-top: 96px;
}
.dt__filler {
padding: 16px 0;
}
.dt__foot {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.dt__card {
transition: none !important;
}
.dt.is-demo .dt__track,
.dt.is-demo .dt__card,
.dt.is-demo .dt__hint,
.dt.is-demo .dt__phaseWait,
.dt.is-demo .dt__phaseIn,
.dt.is-demo .dt__phaseOut {
animation: none !important;
}
.dt.is-demo .dt__card {
opacity: 1 !important;
transform: none !important;
}
.dt.is-demo .dt__track {
transform: translateY(-144px) !important;
}
.dt.is-demo .dt__hint {
opacity: 0 !important;
}
.dt.is-demo .dt__phaseIn {
opacity: 1 !important;
}
.dt.is-demo .dt__phaseWait,
.dt.is-demo .dt__phaseOut {
opacity: 0 !important;
}
}// 방향 감지 — 직전 scrollTop 과 비교해 위로 가는지 아래로 가는지를 정하고
// 들어올 때(is-visible) 와 벗어날 때(is-exit) 모습을 다르게 쓴다
var root = document.getElementById('dt');
var view = document.getElementById('dt-view');
var card = document.getElementById('dt-card');
var lastY = view.scrollTop;
var curDir = 'down';
function readDir() {
// 직전 스크롤 위치와 비교해 진행 방향을 정한다 — 아래로 가면 down, 위로 가면 up
var y = view.scrollTop;
if (y > lastY) curDir = 'down';
else if (y < lastY) curDir = 'up';
root.setAttribute('data-dir', curDir);
lastY = y;
}
var met = false;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
// isIntersecting 이 바뀌는 순간마다 방향 값을 갱신한다 — 직전 비교값(curDir) 을 카드에 찍는다
root.setAttribute('data-dir', curDir);
card.setAttribute('data-dir', curDir);
if (entry.isIntersecting) {
met = true;
card.classList.add('is-visible');
card.classList.remove('is-exit');
root.classList.add('is-enter');
root.classList.remove('is-leave');
} else if (met) {
// 한 번이라도 들어온 뒤에만 퇴장으로 친다 — 첫 적재의 화면 밖 상태는 대기다
card.classList.remove('is-visible');
card.classList.add('is-exit');
root.classList.add('is-leave');
root.classList.remove('is-enter');
}
});
}, { root: view, threshold: 0.2 });
io.observe(card);
view.addEventListener('scroll', function () {
root.classList.remove('is-demo');
readDir();
}, { passive: true });
view.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
view.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') root.classList.remove('is-demo');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DirectionEnterExit.module.scss";
export type DirectionEnterExitProps = {
title?: string;
attr?: string;
hint?: string;
cardNo?: string;
cardText?: string;
filler?: string;
dirDown?: string;
dirUp?: string;
phaseWait?: string;
phaseIn?: string;
phaseOut?: string;
groupLabel?: string;
listLabel?: 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 ICON_DOWN =
'M207.39 140.94A8 8 0 0 0 200 136h-64V40a8 8 0 0 0-16 0v96H56a8 8 0 0 0-5.66 13.66l72 72a8 8 0 0 0 11.32 0l72-72a8 8 0 0 0 1.73-8.72M128 204.69L75.31 152h105.38Z';
const ICON_DOWN_SOFT = 'm200 144l-72 72l-72-72Z';
const ICON_UP =
'm205.66 106.34l-72-72a8 8 0 0 0-11.32 0l-72 72A8 8 0 0 0 56 120h64v96a8 8 0 0 0 16 0v-96h64a8 8 0 0 0 5.66-13.66M75.31 104L128 51.31L180.69 104Z';
const ICON_UP_SOFT = 'M200 112H56l72-72Z';
export default function DirectionEnterExit({
title = "방향 감지 진입·퇴장",
attr = "scrollTop",
hint = "아래로 스크롤",
cardNo = "작품 07",
cardText = "아래로 지나가면 기울며 퇴장",
filler = "다음 섹션",
dirDown = "아래로 이동",
dirUp = "위로 이동",
phaseWait = "스크롤 대기",
phaseIn = "진입 중",
phaseOut = "퇴장 감지",
groupLabel = "방향 감지 진입·퇴장 데모",
listLabel = "카드 목록 — 스크롤 방향에 따라 모습이 달라집니다",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#5b8cf8",
}: DirectionEnterExitProps) {
const [live, setLive] = useState(demo);
const [dir, setDir] = useState<"down" | "up">("down");
const [phase, setPhase] = useState<"wait" | "enter" | "leave">("wait");
const viewRef = useRef<HTMLDivElement>(null) as unknown as Box<HTMLDivElement | null>;
const cardRef = useRef<HTMLElement | null>(null) as unknown as Box<HTMLElement | null>;
const lastY = useRef(0) as unknown as Box<number>;
const met = useRef(false) as unknown as Box<boolean>;
const curDir = useRef<"down" | "up">("down") as unknown as Box<"down" | "up">;
// 직전 스크롤 위치와 비교해 진행 방향을 정한다 — 아래로 가면 down, 위로 가면 up
const readDir = () => {
const el = viewRef.current;
if (!el) return;
const y = el.scrollTop;
if (y > lastY.current) curDir.current = "down";
else if (y < lastY.current) curDir.current = "up";
setDir(curDir.current);
lastY.current = y;
};
useEffect(() => {
if (live) return;
const root = viewRef.current;
const card = cardRef.current;
if (!root || !card) return;
// isIntersecting 이 바뀌는 순간마다 방향 값을 갱신한다 — 직전 비교값(curDir) 을 화면에 반영한다
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setDir(curDir.current);
if (entry.isIntersecting) {
met.current = true;
setPhase("enter");
} else if (met.current) {
// 한 번이라도 들어온 뒤에만 퇴장으로 친다 — 첫 적재의 화면 밖 상태는 대기다
setPhase("leave");
}
});
},
{ root, threshold: 0.2 }
);
io.observe(card);
return () => io.disconnect();
}, [live]);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<section
className={`${styles.dt} ${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.view}
ref={viewRef as unknown as Box<HTMLDivElement>}
onScroll={() => {
setLive(false);
readDir();
}}
onPointerDown={() => setLive(false)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") setLive(false);
}}
tabIndex={0}
role="region"
aria-label={listLabel}
>
<div className={styles.track}>
<p className={styles.hint}>{hint}</p>
<article
ref={(el: HTMLElement | null) => {
cardRef.current = el;
}}
className={`${styles.card} ${styles["dir" + (dir === "down" ? "Down" : "Up")]} ${
phase === "enter" ? styles.isVisible : ""
} ${phase === "leave" ? styles.isExit : ""}`}
>
<b>{cardNo}</b>
<span>{cardText}</span>
</article>
<p className={styles.filler} aria-hidden="true">{filler}</p>
</div>
</div>
<footer className={styles.foot}>
<span className={styles.dirBox} aria-hidden="true">
<span className={`${styles.dir} ${dir === "down" ? styles.on : ""}`}>
<svg className={styles.ico} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor"><path d={ICON_DOWN_SOFT} opacity=".2" /><path d={ICON_DOWN} /></g>
</svg>
<span>{dirDown}</span>
</span>
<span className={`${styles.dir} ${dir === "up" ? styles.on : ""}`}>
<svg className={styles.ico} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor"><path d={ICON_UP_SOFT} opacity=".2" /><path d={ICON_UP} /></g>
</svg>
<span>{dirUp}</span>
</span>
</span>
<span className={styles.phase}>
<span className={`${styles.phaseChip} ${styles.phaseWait} ${phase === "wait" ? styles.on : ""}`}>{phaseWait}</span>
<span className={`${styles.phaseChip} ${styles.phaseIn} ${phase === "enter" ? styles.on : ""}`}>{phaseIn}</span>
<span className={`${styles.phaseChip} ${styles.phaseOut} ${phase === "leave" ? styles.on : ""}`}>{phaseOut}</span>
</span>
</footer>
</section>
);
}06 방향 감지 진입·퇴장 — 측정 누적면적 34.152% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로
이 데모가 속한 글
스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로스크롤 트리거 애니메이션이란 화면을 내리다가 요소가 뷰포트에 들어오는 순간 움직임을 시작하게 하는 장치입니다. GSAP 없이 IntersectionObserver 하나로 아홉 개의 엔진을 직접 만들었고, 아래 격자에 이 글의 데모 전부 보기


