06Direction enter & exit
Compares against the last scroll position to tell up from down, then styles entering and leaving differently. The direction value refreshes every time isIntersecting flips.
Best forPortfolio card flow
isIntersectingscrollTopis-exit

<main class="stage">
<section class="dt is-demo" id="dt" aria-label="Direction enter and exit demo">
<header class="dt__head">
<p class="dt__title">Direction enter and exit</p>
<p class="dt__attr"><code>scrollTop</code></p>
</header>
<div class="dt__view" id="dt-view" tabindex="0" role="region" aria-label="Card list — the look changes with scroll direction">
<div class="dt__track">
<p class="dt__hint">Scroll down</p>
<article class="dt__card" id="dt-card">
<b>Work 07</b>
<span>Tilts away as it passes downward</span>
</article>
<p class="dt__filler" aria-hidden="true">Next section</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>Moving down</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>Moving up</span>
</span>
</span>
<span class="dt__phase">
<span class="dt__phaseWait">Waiting for scroll</span>
<span class="dt__phaseIn">Entering</span>
<span class="dt__phaseOut">Exit detected</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 Direction enter & exit — Measured area 34.152% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · Scroll Trigger Animation: 9 Engines, No GSAP
The post this demo lives in
Scroll Trigger Animation: 9 Engines, No GSAPScroll trigger animation is the device that fires a movement the moment an element enters the viewport. It runs with no See all demos in this post
If you like this effect - similar demos
Scroll01 Scroll hint that bounces only on the first screen9 Reading Progress Bar CSS Patterns, No Library
Scroll06 Dual direction rows9 CSS Marquee Infinite Loop — No JS, Scroll-Driven
Scroll09 Rubber pull — the moment scroll direction reverses, a value pulls like a rubber band and springs backScroll Inertia CSS — 9 Value Patterns