01트리거 엔진 코어
화면 안에 들어왔는지 지켜보다가 is-visible 클래스를 붙이는 엔진 본체입니다. 진입 방향 네 가지와 임계값·한 번만·미리 감지 여백을 전부 data-scroll 데이터 속성으로 HTML 에 적습니다.
쓰임랜딩 섹션 등장
IntersectionObserverdata-thresholdis-visible

<main class="stage">
<section class="te is-demo" id="te" aria-label="스크롤 트리거 엔진">
<header class="te__head">
<p class="te__title">트리거 엔진</p>
<p class="te__attr"><code>data-scroll="up"</code></p>
</header>
<div class="te__view" id="te-view" tabindex="0" role="region" aria-label="섹션 목록 — 스크롤하면 순서대로 등장합니다">
<div class="te__track">
<article class="te__card" data-scroll="up" data-threshold="0.4" data-once="true"><b>01</b><span>아래에서 올라오며</span></article>
<article class="te__card" data-scroll="left" data-threshold="0.4" data-once="true"><b>02</b><span>왼쪽에서 밀려오며</span></article>
<article class="te__card" data-scroll="right" data-threshold="0.4" data-once="true"><b>03</b><span>오른쪽에서 밀려오며</span></article>
<article class="te__card" data-scroll="zoom" data-threshold="0.4" data-once="true"><b>04</b><span>작게 있다 커지며</span></article>
</div>
</div>
<footer class="te__foot">
<span class="te__dot" aria-hidden="true"></span>
<span class="te__state">화면에 들어오면 <b>is-visible</b></span>
</footer>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 트리거 엔진 코어 — 네 방향 진입을 하나의 관찰자 설정으로 재생한다
// 미리보기(.is-demo)는 CSS 키프레임이 스크롤을 대신하고, 손이 닿으면 JS 가 IntersectionObserver 를 붙인다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.te {
position: relative;
box-sizing: border-box;
width: min(320px, 92vw);
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;
}
.te__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.te__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.te__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba($subject-ink, .72);
code { font-family: "Consolas", "Menlo", monospace; }
}
.te__view {
height: 100px;
overflow-y: auto;
padding: 0 $sp-3;
outline: none;
}
.te__track {
display: grid;
gap: $sp-2;
padding-bottom: 140px;
}
.te__card {
display: flex;
align-items: center;
gap: $sp-2;
box-sizing: border-box;
padding: $sp-4;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
opacity: 0;
transition: opacity $dur-enter $easing, transform $dur-enter $easing;
b { font-size: 20px; font-weight: 800; line-height: 1; }
span { font-size: 12px; font-weight: 700; line-height: 1.3; }
}
.te__card[data-scroll="up"] { transform: translateY(18px); }
.te__card[data-scroll="left"] { transform: translateX(-24px); }
.te__card[data-scroll="right"] { transform: translateX(24px); }
// 카드는 스스로 완결한 요소라 중심에서 커진다 (기본 origin 유지)
.te__card[data-scroll="zoom"] { transform: scale(.82); }
.te__card.is-visible {
opacity: 1;
transform: none;
}
.te__foot {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-top: 1px solid rgba($subject-ink, .12);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
b { color: $subject-orange-deep; }
}
.te__dot {
width: 8px;
height: 8px;
border-radius: $r-pill;
background: $color;
}
// 미리보기 — 스크롤 대신 트랙을 밀어 올리고 카드가 순서대로 들어오는 것처럼 보인다
.te.is-demo {
.te__track {
animation-name: te-flow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 나타남은 한 프레임 컷(te-show), 이동은 이징(te-rise) — 페이드 중간 프레임이 유령으로 찍히지 않는다
.te__card {
animation-name: te-show, te-rise;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.te__card:nth-child(1) { animation-delay: 0s, 0s; }
.te__card:nth-child(2) { animation-delay: $duration * .12, $duration * .12; }
.te__card:nth-child(3) { animation-delay: $duration * .24, $duration * .24; }
.te__card:nth-child(4) { animation-delay: $duration * .36, $duration * .36; }
.te__dot {
animation-name: te-blink;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@keyframes te-flow {
0%, 8% { transform: translateY(0); }
46%, 54% { transform: translateY(-80px); }
88%, 100% { transform: translateY(-160px); }
}
@keyframes te-show {
0% { opacity: 0; }
4%, 92% { opacity: 1; }
96% { opacity: 0; }
}
@keyframes te-rise {
0% { transform: translateY(18px); }
28%, 92% { transform: none; }
100% { transform: translateY(18px); }
}
@keyframes te-blink {
0% { background: rgba($subject-ink, .2); }
24% { background: $color; }
90% { background: rgba($subject-ink, .2); }
}
// 320×200 무대는 세로 여유가 거의 없다 — 목록 칸을 줄여 머리·바닥과 함께 화면 안에 넣는다
@media (max-width: 340px) {
.te { width: 100%; }
.te__view { height: 72px; }
.te__card { padding: $sp-2; }
.te__card b { font-size: 16px; }
.te__card span { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.te__card { transition: none !important; }
.te.is-demo .te__track,
.te.is-demo .te__card,
.te.is-demo .te__dot { animation: none !important; }
// 정지 상태 — 카드가 전부 보인다
.te.is-demo .te__card { opacity: 1 !important; transform: none !important; }
.te.is-demo .te__dot { background: $color !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);
}
.te {
position: relative;
box-sizing: border-box;
width: min(320px, 92vw);
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);
}
.te__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.te__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.te__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.te__attr code {
font-family: "Consolas", "Menlo", monospace;
}
.te__view {
height: 100px;
overflow-y: auto;
padding: 0 12px;
outline: none;
}
.te__track {
display: grid;
gap: 8px;
padding-bottom: 140px;
}
.te__card {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
padding: 16px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
opacity: 0;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.te__card b {
font-size: 20px;
font-weight: 800;
line-height: 1;
}
.te__card span {
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.te__card[data-scroll=up] {
transform: translateY(18px);
}
.te__card[data-scroll=left] {
transform: translateX(-24px);
}
.te__card[data-scroll=right] {
transform: translateX(24px);
}
.te__card[data-scroll=zoom] {
transform: scale(0.82);
}
.te__card.is-visible {
opacity: 1;
transform: none;
}
.te__foot {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid rgba(23, 20, 26, 0.12);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.te__foot b {
color: #c93000;
}
.te__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #2f6df6;
}
.te.is-demo .te__track {
animation-name: te-flow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.te.is-demo .te__card {
animation-name: te-show, te-rise;
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;
}
.te.is-demo .te__card:nth-child(1) {
animation-delay: 0s, 0s;
}
.te.is-demo .te__card:nth-child(2) {
animation-delay: 0.24s, 0.24s;
}
.te.is-demo .te__card:nth-child(3) {
animation-delay: 0.48s, 0.48s;
}
.te.is-demo .te__card:nth-child(4) {
animation-delay: 0.72s, 0.72s;
}
.te.is-demo .te__dot {
animation-name: te-blink;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes te-flow {
0%, 8% {
transform: translateY(0);
}
46%, 54% {
transform: translateY(-80px);
}
88%, 100% {
transform: translateY(-160px);
}
}
@keyframes te-show {
0% {
opacity: 0;
}
4%, 92% {
opacity: 1;
}
96% {
opacity: 0;
}
}
@keyframes te-rise {
0% {
transform: translateY(18px);
}
28%, 92% {
transform: none;
}
100% {
transform: translateY(18px);
}
}
@keyframes te-blink {
0% {
background: rgba(23, 20, 26, 0.2);
}
24% {
background: #2f6df6;
}
90% {
background: rgba(23, 20, 26, 0.2);
}
}
@media (max-width: 340px) {
.te {
width: 100%;
}
.te__view {
height: 72px;
}
.te__card {
padding: 8px;
}
.te__card b {
font-size: 16px;
}
.te__card span {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.te__card {
transition: none !important;
}
.te.is-demo .te__track,
.te.is-demo .te__card,
.te.is-demo .te__dot {
animation: none !important;
}
.te.is-demo .te__card {
opacity: 1 !important;
transform: none !important;
}
.te.is-demo .te__dot {
background: #2f6df6 !important;
}
}// 트리거 엔진 코어 — data-scroll 속성으로 방향·임계값·한 번만 여부를 HTML 에서 정한다
var root = document.getElementById('te');
var view = document.getElementById('te-view');
var cards = Array.prototype.slice.call(document.querySelectorAll('.te__card'));
var observers = [];
function attach(card) {
var threshold = parseFloat(card.getAttribute('data-threshold')) || 0;
var once = card.getAttribute('data-once') === 'true';
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
if (once) io.unobserve(entry.target);
} else if (!once) {
entry.target.classList.remove('is-visible');
}
});
}, { root: view, rootMargin: '0px 0px -8px 0px', threshold: threshold });
io.observe(card);
observers.push(io);
}
function start() {
root.classList.remove('is-demo');
if (observers.length === 0) cards.forEach(attach);
}
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 "./TriggerEngineCore.module.scss";
export type TriggerEngineCoreProps = {
title?: string;
attr?: string;
state?: string;
cards?: { dir: "up" | "left" | "right" | "zoom"; label: 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 DEFAULT_CARDS: { dir: "up" | "left" | "right" | "zoom"; label: string }[] = [
{ dir: "up", label: "아래에서 올라오며" },
{ dir: "left", label: "왼쪽에서 밀려오며" },
{ dir: "right", label: "오른쪽에서 밀려오며" },
{ dir: "zoom", label: "작게 있다 커지며" },
];
export default function TriggerEngineCore({
title = "트리거 엔진",
attr = 'data-scroll="up"',
state = "화면에 들어오면",
cards = DEFAULT_CARDS,
groupLabel = "스크롤 트리거 엔진",
listLabel = "섹션 목록 — 스크롤하면 순서대로 등장합니다",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: TriggerEngineCoreProps) {
const [live, setLive] = useState(demo);
const [seen, setSeen] = useState<number[]>([]);
const viewRef = 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 root = viewRef.current;
if (!root) return;
// 카드마다 관찰자를 하나씩 — 임계값이 항목마다 다를 수 있으므로 공유하지 않는다
const observers = cardRefs.current.map((el, i) => {
if (!el) return null;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setSeen((prev) => (prev.indexOf(i) === -1 ? prev.concat(i) : prev));
io.unobserve(entry.target);
}
});
},
{ root, rootMargin: "0px 0px -8px 0px", threshold: 0.4 }
);
io.observe(el);
return io;
});
return () => {
observers.forEach((io) => io && io.disconnect());
};
}, [live]);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<section
className={`${styles.te} ${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)}
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}>
{cards.map((card, i) => (
<article
key={card.dir}
ref={(el: HTMLElement | null) => {
cardRefs.current[i] = el;
}}
className={`${styles.card} ${styles[card.dir]} ${
seen.indexOf(i) !== -1 ? styles.isVisible : ""
}`}
>
<b>{String(i + 1).padStart(2, "0")}</b>
<span>{card.label}</span>
</article>
))}
</div>
</div>
<footer className={styles.foot}>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.state}>
{state} <b>is-visible</b>
</span>
</footer>
</section>
);
}01 트리거 엔진 코어 — 측정 누적면적 20.597% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로
이 데모가 속한 글
스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로스크롤 트리거 애니메이션이란 화면을 내리다가 요소가 뷰포트에 들어오는 순간 움직임을 시작하게 하는 장치입니다. GSAP 없이 IntersectionObserver 하나로 아홉 개의 엔진을 직접 만들었고, 아래 격자에 이 글의 데모 전부 보기


