02임계값 게이지
요소가 화면에 얼마나 들어왔는지를 intersectionRatio 0 에서 1 로 게이지와 숫자로 실측해 보여줍니다. 임계값 후보를 바꾸면 불이 켜지는 시점이 달라집니다.
쓰임긴 목록 카드 등장 시점 조율
intersectionRatiothreshold: [0, 0.25, 0.5, 1]scaleX

<main class="stage">
<section class="tg is-demo" id="tg" aria-label="임계값 게이지">
<header class="tg__head">
<p class="tg__title">임계값 게이지</p>
<p class="tg__attr"><code>threshold: [0, 0.25, 0.5, 1]</code></p>
</header>
<div class="tg__body">
<div class="tg__view" id="tg-view" tabindex="0" role="region" aria-label="타깃 뷰포트 — 스크롤해 보세요">
<div class="tg__track" id="tg-track">
<p class="tg__lead">타깃이 아래에서 올라옵니다</p>
<div class="tg__target" id="tg-target">
<b>타깃 요소</b>
<span>높이의 몇 %가 들어왔나</span>
</div>
</div>
</div>
<div class="tg__panel">
<p class="tg__read" aria-live="polite">
<b class="tg__numcur" id="tg-num">0.00</b>
<span class="tg__nums" aria-hidden="true"><b>0.00</b><b>0.25</b><b>0.50</b><b>1.00</b></span>
</p>
<div class="tg__gauge" aria-hidden="true">
<i class="tg__fill" id="tg-fill"></i>
<i class="tg__tick tg__tick--0"></i>
<i class="tg__tick tg__tick--25"></i>
<i class="tg__tick tg__tick--50"></i>
<i class="tg__tick tg__tick--100"></i>
</div>
<p class="tg__scale" aria-hidden="true"><span class="tg__s0">0</span><span class="tg__s25">0.25</span><span class="tg__s50">0.5</span><span class="tg__s100">1</span></p>
<p class="tg__candt">임계값 후보</p>
<div class="tg__cands" id="tg-cands">
<button type="button" class="tg__cand" data-th="0" aria-pressed="false">0</button>
<button type="button" class="tg__cand is-on" data-th="0.25" aria-pressed="true">0.25</button>
<button type="button" class="tg__cand" data-th="0.5" aria-pressed="false">0.5</button>
<button type="button" class="tg__cand" data-th="1" aria-pressed="false">1</button>
</div>
<p class="tg__lightline">
<span class="tg__led" id="tg-led" aria-hidden="true"></span>
<span class="tg__state">
<b class="tg__stcur" id="tg-state">대기</b>
<span class="tg__sts" aria-hidden="true"><b>대기</b><b>임계값 도달</b></span>
</span>
</p>
</div>
</div>
<footer class="tg__foot">임계값을 지날 때만 관찰자가 알려줍니다</footer>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 임계값 게이지 — intersectionRatio 를 게이지 막대(scaleX) 와 숫자로 실측해 보여준다
// 관찰자는 threshold 후보를 지날 때만 보고하므로 숫자·게이지·불은 계단(steps 컷)으로 갱신하고
// 타깃 카드의 진입 움직임만 이징(transform) 으로 분리한다 — 유령 프레임 방지
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.tg {
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;
}
.tg__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.tg__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.tg__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba($subject-ink, .72);
code { font-family: "Consolas", "Menlo", monospace; }
}
.tg__body {
display: grid;
grid-template-columns: 1fr 188px;
gap: $sp-3;
padding: 0 $sp-3 $sp-2;
}
.tg__view {
box-sizing: border-box;
height: 158px;
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;
}
.tg__track {
position: relative; // 안내 문구를 절대 위치로 띄워 줄이 바뀌어도 타깃 위치가 흔들리지 않게 한다
padding-top: $sp-2;
padding-bottom: $sp-6; // 스크롤 여유
}
.tg__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);
}
.tg__target {
margin-top: 150px; // 타깃 top 이 뷰포트 바닥(158px = 8 + 150) 에 딱 닿게 — 진입 전 intersectionRatio 0
box-sizing: border-box;
height: 88px; // 0.25 배수(22·44·88) 로 잡아 임계값 통과 거리가 정수가 되게 한다
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-1;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
b { font-size: 14px; font-weight: 800; line-height: 1.3; }
span { font-size: 10px; font-weight: 700; line-height: 1.3; }
}
.tg__panel { display: flex; flex-direction: column; }
.tg__read {
position: relative;
height: 32px;
margin: 0;
}
.tg__numcur,
.tg__nums b {
position: absolute;
inset: 0;
display: flex;
align-items: center;
font-size: 22px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.tg__nums { display: none; }
.tg__nums b { opacity: 0; }
.tg.is-demo .tg__nums { display: block; }
.tg.is-demo .tg__numcur { visibility: hidden; } // 미리보기 동안엔 계단 레이어가 값을 보여준다
.tg__gauge {
position: relative;
height: 10px;
margin-top: $sp-2;
border-radius: $r-pill;
background: rgba($subject-ink, .14);
}
.tg__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 채움 막대 — 시작 변에서 자란다 (붙는 지점이 있는 요소 예외)
}
.tg__tick {
position: absolute;
top: -3px;
width: 2px;
height: 16px;
border-radius: $r-pill;
background: rgba($subject-ink, .35);
}
.tg__tick--0 { left: 0; }
.tg__tick--25 { left: 25%; }
.tg__tick--50 { left: 50%; transform: translateX(-1px); }
.tg__tick--100 { left: 100%; transform: translateX(-2px); }
.tg__scale {
position: relative;
height: 12px;
margin: $sp-1 0 0;
font-size: 9px;
font-weight: 700;
line-height: 12px;
color: rgba($subject-ink, .72);
}
.tg__scale span { position: absolute; }
.tg__s0 { left: 0; }
.tg__s25 { left: 25%; }
.tg__s50 { left: 50%; transform: translateX(-50%); }
.tg__s100 { left: 100%; transform: translateX(-100%); }
.tg__candt {
margin: $sp-2 0 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
.tg__cands {
display: flex;
gap: $sp-1;
margin-top: $sp-1;
}
.tg__cand {
flex: 1 1 auto;
padding: $sp-1 0;
border: 1px solid rgba($subject-ink, .25);
border-radius: $r-xs;
background: none;
color: rgba($subject-ink, .72);
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.tg__cand.is-on {
border-color: $subject-ink;
background: $subject-ink;
color: $subject-cream;
}
// 후보 칩의 두 얼굴 — 미리보기 키프레임도 같은 값을 쓰도록 믹스인으로 한 곳에 둔다
@mixin cand-off {
border-color: rgba($subject-ink, .25);
background-color: transparent;
color: rgba($subject-ink, .72);
}
@mixin cand-on {
border-color: $subject-ink;
background-color: $subject-ink;
color: $subject-cream;
}
.tg__lightline {
display: flex;
align-items: center;
gap: $sp-2;
margin: $sp-2 0 0;
}
.tg__led {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba($subject-ink, .18);
}
.tg__led.is-on { background: $subject-orange-deep; }
.tg__state {
position: relative;
flex: 1 1 auto; // 라벨 칸이 남은 폭을 다 가져간다 — 절대 배치된 컷 레이어가 설 자리
min-width: 0;
white-space: nowrap; // 「임계값 도달」이 여러 줄로 쪼개지지 않게
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.tg__stcur { font-weight: 700; }
.tg__stcur.is-on { color: $subject-orange-deep; }
.tg__sts { display: none; }
.tg__sts b {
position: absolute;
left: 0;
top: 0;
opacity: 0;
}
.tg__sts b:last-child { color: $subject-orange-deep; }
.tg.is-demo .tg__sts { display: block; position: absolute; inset: 0; }
.tg.is-demo .tg__stcur { visibility: hidden; } // 미리보기 동안엔 컷 레이어가 상태 글자를 보여준다
.tg__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);
}
// 미리보기 — 스크롤 대신 트랙을 올리고, 숫자·게이지·불은 임계값 통과 순간(30·42·58%) 에 계단으로 갱신한다
.tg.is-demo {
.tg__track {
animation-name: tg-flow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tg__nums b {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg__nums b:nth-child(1) { animation-name: tg-n1; }
.tg__nums b:nth-child(2) { animation-name: tg-n2; }
.tg__nums b:nth-child(3) { animation-name: tg-n3; }
.tg__nums b:nth-child(4) { animation-name: tg-n4; }
.tg__fill {
animation-name: tg-fill;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg__led {
animation-name: tg-led;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg__sts b {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg__sts b:nth-child(1) { animation-name: tg-st1; }
.tg__sts b:nth-child(2) { animation-name: tg-st2; }
// 미리보기 동안엔 활성 후보도 같이 걷는다 — 어느 프레임을 잘라도 강조된 후보와 표시된 비율이 같은 값이다
.tg__cand.is-on { @include cand-off; } // 정적 선택(0.25) 은 잠시 내려놓는다
.tg__cand {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg__cand:nth-child(1) { animation-name: tg-c1; }
.tg__cand:nth-child(2) { animation-name: tg-c2; }
.tg__cand:nth-child(3) { animation-name: tg-c3; }
.tg__cand:nth-child(4) { animation-name: tg-c4; }
}
@keyframes tg-flow {
0%, 12% { transform: translateY(0); }
30% { transform: translateY(-22px); } // 타깃 22px 진입 — 0.25 통과
42% { transform: translateY(-44px); } // 0.50 통과
58% { transform: translateY(-88px); } // 1.00 통과 (완전 진입)
66%, 100% { transform: translateY(-110px); } // 도달 상태로 끝까지 — 재시작은 0% 점프
}
@keyframes tg-n1 { 0% { opacity: 1; } 30%, 100% { opacity: 0; } }
@keyframes tg-n2 { 0% { opacity: 0; } 30% { opacity: 1; } 42%, 100% { opacity: 0; } }
@keyframes tg-n3 { 0% { opacity: 0; } 42% { opacity: 1; } 58%, 100% { opacity: 0; } }
@keyframes tg-n4 { 0% { opacity: 0; } 58%, 100% { opacity: 1; } }
@keyframes tg-fill {
0%, 12% { transform: scaleX(0); }
30% { transform: scaleX(.25); }
42% { transform: scaleX(.5); }
58%, 100% { transform: scaleX(1); }
}
@keyframes tg-led {
0% { background: rgba($subject-ink, .18); }
30%, 100% { background: $subject-orange-deep; } // 첫 후보를 지난 순간 점등, 이후로는 계속 도달 상태
}
// 후보 강조 — 숫자·게이지와 같은 시각(30·42·58%) 에 다음 후보로 넘어간다
@keyframes tg-c1 { 0% { @include cand-on; } 30%, 100% { @include cand-off; } }
@keyframes tg-c2 { 0% { @include cand-off; } 30% { @include cand-on; } 42%, 100% { @include cand-off; } }
@keyframes tg-c3 { 0% { @include cand-off; } 42% { @include cand-on; } 58%, 100% { @include cand-off; } }
@keyframes tg-c4 { 0% { @include cand-off; } 58%, 100% { @include cand-on; } }
@keyframes tg-st1 {
0% { opacity: 1; }
30%, 100% { opacity: 0; }
}
@keyframes tg-st2 {
0% { opacity: 0; }
30%, 100% { opacity: 1; }
}
@media (max-width: 340px) {
.tg__head { padding: $sp-1 $sp-2; }
.tg__body { grid-template-columns: 1fr 148px; gap: $sp-2; padding: 0 $sp-2 $sp-1; }
.tg__view { height: 112px; }
.tg__target { margin-top: 104px; }
.tg__read { height: 26px; }
.tg__numcur, .tg__nums b { font-size: 18px; }
.tg__gauge { margin-top: $sp-1; }
.tg__candt { margin-top: $sp-1; }
.tg__lightline { margin-top: $sp-1; }
.tg__foot { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__track,
.tg.is-demo .tg__nums b,
.tg.is-demo .tg__fill,
.tg.is-demo .tg__led,
.tg.is-demo .tg__sts b,
.tg.is-demo .tg__cand { animation: none !important; }
.tg.is-demo .tg__cand.is-on { @include cand-on; } // 정지 상태에선 정적 선택(0.25) 이 그대로 보인다
.tg.is-demo .tg__nums,
.tg.is-demo .tg__sts { display: none !important; }
.tg.is-demo .tg__numcur,
.tg.is-demo .tg__stcur { visibility: visible !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);
}
.tg {
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);
}
.tg__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.tg__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.tg__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.tg__attr code {
font-family: "Consolas", "Menlo", monospace;
}
.tg__body {
display: grid;
grid-template-columns: 1fr 188px;
gap: 12px;
padding: 0 12px 8px;
}
.tg__view {
box-sizing: border-box;
height: 158px;
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;
}
.tg__track {
position: relative;
padding-top: 8px;
padding-bottom: 24px;
}
.tg__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);
}
.tg__target {
margin-top: 150px;
box-sizing: border-box;
height: 88px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 8px 12px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
}
.tg__target b {
font-size: 14px;
font-weight: 800;
line-height: 1.3;
}
.tg__target span {
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.tg__panel {
display: flex;
flex-direction: column;
}
.tg__read {
position: relative;
height: 32px;
margin: 0;
}
.tg__numcur,
.tg__nums b {
position: absolute;
inset: 0;
display: flex;
align-items: center;
font-size: 22px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.tg__nums {
display: none;
}
.tg__nums b {
opacity: 0;
}
.tg.is-demo .tg__nums {
display: block;
}
.tg.is-demo .tg__numcur {
visibility: hidden;
}
.tg__gauge {
position: relative;
height: 10px;
margin-top: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.tg__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
}
.tg__tick {
position: absolute;
top: -3px;
width: 2px;
height: 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.35);
}
.tg__tick--0 {
left: 0;
}
.tg__tick--25 {
left: 25%;
}
.tg__tick--50 {
left: 50%;
transform: translateX(-1px);
}
.tg__tick--100 {
left: 100%;
transform: translateX(-2px);
}
.tg__scale {
position: relative;
height: 12px;
margin: 4px 0 0;
font-size: 9px;
font-weight: 700;
line-height: 12px;
color: rgba(23, 20, 26, 0.72);
}
.tg__scale span {
position: absolute;
}
.tg__s0 {
left: 0;
}
.tg__s25 {
left: 25%;
}
.tg__s50 {
left: 50%;
transform: translateX(-50%);
}
.tg__s100 {
left: 100%;
transform: translateX(-100%);
}
.tg__candt {
margin: 8px 0 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.tg__cands {
display: flex;
gap: 4px;
margin-top: 4px;
}
.tg__cand {
flex: 1 1 auto;
padding: 4px 0;
border: 1px solid rgba(23, 20, 26, 0.25);
border-radius: 4px;
background: none;
color: rgba(23, 20, 26, 0.72);
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.tg__cand.is-on {
border-color: #17141a;
background: #17141a;
color: #fff7e6;
}
.tg__lightline {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 0;
}
.tg__led {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.18);
}
.tg__led.is-on {
background: #c93000;
}
.tg__state {
position: relative;
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.tg__stcur {
font-weight: 700;
}
.tg__stcur.is-on {
color: #c93000;
}
.tg__sts {
display: none;
}
.tg__sts b {
position: absolute;
left: 0;
top: 0;
opacity: 0;
}
.tg__sts b:last-child {
color: #c93000;
}
.tg.is-demo .tg__sts {
display: block;
position: absolute;
inset: 0;
}
.tg.is-demo .tg__stcur {
visibility: hidden;
}
.tg__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);
}
.tg.is-demo .tg__track {
animation-name: tg-flow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.tg.is-demo .tg__nums b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg.is-demo .tg__nums b:nth-child(1) {
animation-name: tg-n1;
}
.tg.is-demo .tg__nums b:nth-child(2) {
animation-name: tg-n2;
}
.tg.is-demo .tg__nums b:nth-child(3) {
animation-name: tg-n3;
}
.tg.is-demo .tg__nums b:nth-child(4) {
animation-name: tg-n4;
}
.tg.is-demo .tg__fill {
animation-name: tg-fill;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg.is-demo .tg__led {
animation-name: tg-led;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg.is-demo .tg__sts b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg.is-demo .tg__sts b:nth-child(1) {
animation-name: tg-st1;
}
.tg.is-demo .tg__sts b:nth-child(2) {
animation-name: tg-st2;
}
.tg.is-demo .tg__cand.is-on {
border-color: rgba(23, 20, 26, 0.25);
background-color: transparent;
color: rgba(23, 20, 26, 0.72);
}
.tg.is-demo .tg__cand {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tg.is-demo .tg__cand:nth-child(1) {
animation-name: tg-c1;
}
.tg.is-demo .tg__cand:nth-child(2) {
animation-name: tg-c2;
}
.tg.is-demo .tg__cand:nth-child(3) {
animation-name: tg-c3;
}
.tg.is-demo .tg__cand:nth-child(4) {
animation-name: tg-c4;
}
@keyframes tg-flow {
0%, 12% {
transform: translateY(0);
}
30% {
transform: translateY(-22px);
}
42% {
transform: translateY(-44px);
}
58% {
transform: translateY(-88px);
}
66%, 100% {
transform: translateY(-110px);
}
}
@keyframes tg-n1 {
0% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes tg-n2 {
0% {
opacity: 0;
}
30% {
opacity: 1;
}
42%, 100% {
opacity: 0;
}
}
@keyframes tg-n3 {
0% {
opacity: 0;
}
42% {
opacity: 1;
}
58%, 100% {
opacity: 0;
}
}
@keyframes tg-n4 {
0% {
opacity: 0;
}
58%, 100% {
opacity: 1;
}
}
@keyframes tg-fill {
0%, 12% {
transform: scaleX(0);
}
30% {
transform: scaleX(0.25);
}
42% {
transform: scaleX(0.5);
}
58%, 100% {
transform: scaleX(1);
}
}
@keyframes tg-led {
0% {
background: rgba(23, 20, 26, 0.18);
}
30%, 100% {
background: #c93000;
}
}
@keyframes tg-c1 {
0% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
30%, 100% {
border-color: rgba(23, 20, 26, 0.25);
background-color: transparent;
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes tg-c2 {
0% {
border-color: rgba(23, 20, 26, 0.25);
background-color: transparent;
color: rgba(23, 20, 26, 0.72);
}
30% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
42%, 100% {
border-color: rgba(23, 20, 26, 0.25);
background-color: transparent;
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes tg-c3 {
0% {
border-color: rgba(23, 20, 26, 0.25);
background-color: transparent;
color: rgba(23, 20, 26, 0.72);
}
42% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
58%, 100% {
border-color: rgba(23, 20, 26, 0.25);
background-color: transparent;
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes tg-c4 {
0% {
border-color: rgba(23, 20, 26, 0.25);
background-color: transparent;
color: rgba(23, 20, 26, 0.72);
}
58%, 100% {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
}
@keyframes tg-st1 {
0% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes tg-st2 {
0% {
opacity: 0;
}
30%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.tg__head {
padding: 4px 8px;
}
.tg__body {
grid-template-columns: 1fr 148px;
gap: 8px;
padding: 0 8px 4px;
}
.tg__view {
height: 112px;
}
.tg__target {
margin-top: 104px;
}
.tg__read {
height: 26px;
}
.tg__numcur, .tg__nums b {
font-size: 18px;
}
.tg__gauge {
margin-top: 4px;
}
.tg__candt {
margin-top: 4px;
}
.tg__lightline {
margin-top: 4px;
}
.tg__foot {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tg.is-demo .tg__track,
.tg.is-demo .tg__nums b,
.tg.is-demo .tg__fill,
.tg.is-demo .tg__led,
.tg.is-demo .tg__sts b,
.tg.is-demo .tg__cand {
animation: none !important;
}
.tg.is-demo .tg__cand.is-on {
border-color: #17141a;
background-color: #17141a;
color: #fff7e6;
}
.tg.is-demo .tg__nums,
.tg.is-demo .tg__sts {
display: none !important;
}
.tg.is-demo .tg__numcur,
.tg.is-demo .tg__stcur {
visibility: visible !important;
}
}// 임계값 게이지 — 관찰자는 임계값 후보를 지날 때만 값을 보고한다
// 그래서 숫자와 게이지는 연속이 아니라 계단으로 갱신된다 (이것이 이 데모의 주제다)
var root = document.getElementById('tg');
var view = document.getElementById('tg-view');
var target = document.getElementById('tg-target');
var numEl = document.getElementById('tg-num');
var fill = document.getElementById('tg-fill');
var led = document.getElementById('tg-led');
var stateEl = document.getElementById('tg-state');
var cands = document.getElementById('tg-cands');
var selected = 0.25;
var lastRatio = 0;
var lastIn = false;
var started = false;
function paint(ratio, isIn) {
numEl.textContent = ratio.toFixed(2);
fill.style.transform = 'scaleX(' + ratio + ')';
var on = isIn && ratio >= selected - 0.01;
led.classList.toggle('is-on', on);
stateEl.textContent = on ? '임계값 도달' : '대기';
stateEl.classList.toggle('is-on', on);
}
function start() {
if (started) return;
started = true;
root.classList.remove('is-demo');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
lastIn = entry.isIntersecting;
paint(lastRatio, lastIn);
});
}, { root: view, threshold: [0, 0.25, 0.5, 1] });
io.observe(target);
}
view.addEventListener('scroll', start, { passive: true });
view.addEventListener('pointerdown', start);
view.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === ' ') start();
});
cands.addEventListener('click', function (e) {
var btn = e.target.closest('.tg__cand');
if (!btn) return;
start();
selected = parseFloat(btn.getAttribute('data-th'));
var all = cands.querySelectorAll('.tg__cand');
for (var i = 0; i < all.length; i++) {
var on = all[i] === btn;
all[i].classList.toggle('is-on', on);
all[i].setAttribute('aria-pressed', on ? 'true' : 'false');
}
paint(lastRatio, lastIn);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThresholdGauge.module.scss";
export type ThresholdGaugeProps = {
title?: string;
attr?: string;
lead?: string;
targetTitle?: string;
targetSub?: string;
viewLabel?: string;
groupLabel?: string;
candidatesLabel?: string;
waiting?: string;
reached?: string;
foot?: 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 };
type ClickEvt = { target: { closest: (s: string) => HTMLElement | null } };
const CANDIDATES = [0, 0.25, 0.5, 1];
const NUM_LAYERS = ["0.00", "0.25", "0.50", "1.00"];
export default function ThresholdGauge({
title = "임계값 게이지",
attr = "threshold: [0, 0.25, 0.5, 1]",
lead = "타깃이 아래에서 올라옵니다",
targetTitle = "타깃 요소",
targetSub = "높이의 몇 %가 들어왔나",
viewLabel = "타깃 뷰포트 — 스크롤해 보세요",
groupLabel = "임계값 게이지",
candidatesLabel = "임계값 후보",
waiting = "대기",
reached = "임계값 도달",
foot = "임계값을 지날 때만 관찰자가 알려줍니다",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: ThresholdGaugeProps) {
const [live, setLive] = useState(demo);
const [ratio, setRatio] = useState(0);
const [on, setOn] = useState(false);
const [selected, setSelected] = useState(0.25);
const viewRef = useRef<HTMLDivElement>(null) as unknown as Box<HTMLDivElement | null>;
const targetRef = useRef<HTMLDivElement>(null) as unknown as Box<HTMLDivElement | null>;
const selRef = useRef(0.25) as unknown as Box<number>;
const inRef = useRef(false) as unknown as Box<boolean>;
const ratioRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
if (live) return;
const view = viewRef.current;
const target = targetRef.current;
if (!view || !target) return;
// 관찰자는 임계값 후보를 지날 때만 보고한다 — 숫자·게이지는 계단으로 갱신된다
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const r = entry.intersectionRatio;
ratioRef.current = r;
inRef.current = entry.isIntersecting;
setRatio(r);
setOn(entry.isIntersecting && r >= selRef.current - 0.01);
});
},
{ root: view, threshold: [0, 0.25, 0.5, 1] }
);
io.observe(target);
return () => io.disconnect();
}, [live]);
const pick = (e: ClickEvt) => {
const btn = e.target.closest("button");
if (!btn) return;
setLive(false);
const v = parseFloat(btn.getAttribute("data-th") || "0");
selRef.current = v;
setSelected(v);
setOn(inRef.current && ratioRef.current >= v - 0.01);
};
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<section
className={`${styles.tg} ${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.body}>
<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" || e.key === " ") setLive(false);
}}
tabIndex={0}
role="region"
aria-label={viewLabel}
>
<div className={styles.track}>
<p className={styles.lead}>{lead}</p>
<div className={styles.target} ref={targetRef as unknown as Box<HTMLDivElement>}>
<b>{targetTitle}</b>
<span>{targetSub}</span>
</div>
</div>
</div>
<div className={styles.panel}>
<p className={styles.read} aria-live="polite">
{live ? null : (
<b className={styles.numcur}>{ratio.toFixed(2)}</b>
)}
{live ? (
<span className={styles.nums} aria-hidden="true">
{NUM_LAYERS.map((n) => (<b key={n}>{n}</b>))}
</span>
) : null}
</p>
<div className={styles.gauge} aria-hidden="true">
<i
className={styles.fill}
style={live ? undefined : { transform: `scaleX(${ratio})` }}
/>
<i className={`${styles.tick} ${styles.tick0}`}></i>
<i className={`${styles.tick} ${styles.tick25}`}></i>
<i className={`${styles.tick} ${styles.tick50}`}></i>
<i className={`${styles.tick} ${styles.tick100}`}></i>
</div>
<p className={styles.scale} aria-hidden="true">
<span className={styles.s0}>0</span>
<span className={styles.s25}>0.25</span>
<span className={styles.s50}>0.5</span>
<span className={styles.s100}>1</span>
</p>
<p className={styles.candt}>{candidatesLabel}</p>
<div className={styles.cands} onClick={pick}>
{CANDIDATES.map((v) => (
<button
type="button"
key={v}
className={`${styles.cand} ${selected === v ? styles.isOn : ""}`}
data-th={v}
aria-pressed={selected === v ? "true" : "false"}
>
{v}
</button>
))}
</div>
<p className={styles.lightline}>
<span className={styles.led} aria-hidden="true" />
<span className={styles.state}>
{live ? (
<span className={styles.sts} aria-hidden="true">
<b>{waiting}</b>
<b className={styles.stOn}>{reached}</b>
</span>
) : (
<b className={`${styles.stcur} ${on ? styles.stcurOn : ""}`}>
{on ? reached : waiting}
</b>
)}
</span>
</p>
</div>
</div>
<footer className={styles.foot}>{foot}</footer>
</section>
);
}02 임계값 게이지 — 측정 누적면적 20.001% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로
이 데모가 속한 글
스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로스크롤 트리거 애니메이션이란 화면을 내리다가 요소가 뷰포트에 들어오는 순간 움직임을 시작하게 하는 장치입니다. GSAP 없이 IntersectionObserver 하나로 아홉 개의 엔진을 직접 만들었고, 아래 격자에 이 글의 데모 전부 보기


