04카운터 트리거
뷰포트에 들어온 순간 requestAnimationFrame 으로 숫자를 0 부터 목표까지 올리고, 끝나면 관찰을 멈춥니다. 다시 올라와도 두 번 세지 않습니다.
쓰임회사 소개 실적 지표
requestAnimationFrameeaseOutCubicunobserve

<main class="stage">
<section class="cu is-demo" id="cu" aria-label="카운터 트리거">
<header class="cu__head">
<p class="cu__title">카운터 트리거</p>
<p class="cu__attr"><code>requestAnimationFrame</code></p>
</header>
<div class="cu__view" id="cu-view" tabindex="0" role="region" aria-label="실적 블록 — 스크롤해 보세요">
<div class="cu__track">
<p class="cu__lead">아래로 스크롤하면 숫자가 셉니다</p>
<article class="cu__card" id="cu-card">
<p class="cu__cap">완료한 프로젝트</p>
<p class="cu__num">
<b class="cu__numcur" id="cu-num">0</b><span class="cu__tape" aria-hidden="true"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i><i>10</i><i>11</i><i>12</i><i>13</i><i>14</i><i>15</i><i>16</i><i>17</i><i>18</i><i>19</i><i>20</i><i>21</i><i>22</i><i>23</i><i>24</i><i>25</i><i>26</i><i>27</i><i>28</i><i>29</i><i>30</i><i>31</i><i>32</i><i>33</i><i>34</i><i>35</i><i>36</i><i>37</i><i>38</i><i>39</i><i>40</i><i>41</i><i>42</i><i>43</i><i>44</i><i>45</i><i>46</i><i>47</i><i>48</i><i>49</i><i>50</i><i>51</i><i>52</i><i>53</i><i>54</i><i>55</i><i>56</i><i>57</i><i>58</i><i>59</i><i>60</i><i>61</i><i>62</i><i>63</i><i>64</i><i>65</i><i>66</i><i>67</i><i>68</i><i>69</i><i>70</i><i>71</i><i>72</i><i>73</i><i>74</i><i>75</i><i>76</i><i>77</i><i>78</i><i>79</i><i>80</i><i>81</i><i>82</i><i>83</i><i>84</i><i>85</i><i>86</i><i>87</i><i>88</i><i>89</i><i>90</i><i>91</i><i>92</i><i>93</i><i>94</i><i>95</i><i>96</i><i>97</i><i>98</i><i>99</i><i>100</i><i>101</i><i>102</i><i>103</i><i>104</i><i>105</i><i>106</i><i>107</i><i>108</i><i>109</i><i>110</i><i>111</i><i>112</i><i>113</i><i>114</i><i>115</i><i>116</i><i>117</i><i>118</i><i>119</i><i>120</i><i>121</i><i>122</i><i>123</i><i>124</i><i>125</i><i>126</i><i>127</i><i>128</i><i>129</i><i>130</i><i>131</i><i>132</i><i>133</i><i>134</i><i>135</i><i>136</i><i>137</i><i>138</i><i>139</i><i>140</i><i>141</i><i>142</i><i>143</i><i>144</i><i>145</i><i>146</i><i>147</i><i>148</i><i>149</i><i>150</i><i>151</i><i>152</i><i>153</i><i>154</i><i>155</i><i>156</i><i>157</i><i>158</i><i>159</i><i>160</i><i>161</i><i>162</i><i>163</i><i>164</i><i>165</i><i>166</i><i>167</i><i>168</i><i>169</i><i>170</i><i>171</i><i>172</i><i>173</i><i>174</i><i>175</i><i>176</i><i>177</i><i>178</i><i>179</i><i>180</i><i>181</i><i>182</i><i>183</i><i>184</i><i>185</i><i>186</i><i>187</i><i>188</i><i>189</i><i>190</i><i>191</i><i>192</i><i>193</i><i>194</i><i>195</i><i>196</i><i>197</i><i>198</i><i>199</i><i>200</i><i>201</i><i>202</i><i>203</i><i>204</i><i>205</i><i>206</i><i>207</i><i>208</i><i>209</i><i>210</i><i>211</i><i>212</i><i>213</i><i>214</i><i>215</i><i>216</i><i>217</i><i>218</i><i>219</i><i>220</i><i>221</i><i>222</i><i>223</i><i>224</i><i>225</i><i>226</i><i>227</i><i>228</i><i>229</i><i>230</i><i>231</i><i>232</i><i>233</i><i>234</i><i>235</i><i>236</i><i>237</i><i>238</i><i>239</i><i>240</i><i>241</i><i>242</i><i>243</i><i>244</i><i>245</i><i>246</i><i>247</i><i>248</i></span>
</p>
<span class="cu__bar" aria-hidden="true"><i class="cu__barfill" id="cu-bar"></i></span>
<p class="cu__done" id="cu-done">카운트 완료 — 관찰 종료</p>
</article>
<p class="cu__more">계속 내려도 더 없습니다</p>
</div>
</div>
<footer class="cu__foot">다시 올라와도 두 번 세지 않습니다</footer>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 카운터 트리거 — 뷰포트 진입 순간 requestAnimationFrame 으로 0 부터 목표까지 올린다
// $easing 은 easeOutCubic 의 CSS 베지어 근사(주제 그 자체라 토큰 대신 이 곡선을 쓴다)
$duration: $dur-loop;
$easing: cubic-bezier(.215, .61, .355, 1);
$color: $subject-blue;
$tape-row: 40px; // 카운터 테이프 한 칸 높이 — 창 높이와 같아야 숫자가 정확히 정렬된다
@include stage($stage-ink);
.cu {
// 미리보기가 트랙을 밀어 올리는 거리. 카드와 그 아래 한 줄이 모두 뷰포트 안에 들어가는 값이라
// 어느 프레임을 poster 로 잘라도 경계에 반쯤 걸친 글자가 남지 않는다 (좁은폭에서는 아래 미디어쿼리가 다시 잡는다)
--cu-flow: -160px;
box-sizing: border-box;
width: min(400px, 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;
}
.cu__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.cu__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.cu__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba($subject-ink, .72);
code { font-family: "Consolas", "Menlo", monospace; }
}
.cu__view {
box-sizing: border-box;
height: 170px;
overflow-y: auto;
margin: 0 $sp-3;
padding: 0 $sp-2;
outline: none;
background: rgba($subject-ink, .05);
border: 1px solid rgba($subject-ink, .14);
border-radius: $r-sm;
}
.cu__track {
padding-top: $sp-2;
padding-bottom: $sp-6; // 스크롤 여유
}
.cu__lead {
margin: 0 0 148px; // 카드 top 이 뷰포트 바닥(170px) 에 딱 닿게 — 진입 전 관찰되지 않는다
font-size: 11px;
font-weight: 700;
line-height: 14px;
color: rgba($subject-ink, .72);
}
.cu__card {
box-sizing: border-box;
display: grid;
gap: $sp-2;
padding: $sp-3;
border-radius: $r-control;
background: rgba($subject-ink, .04);
border: 1px solid rgba($subject-ink, .12);
text-align: center;
}
.cu__cap {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
.cu__num {
position: relative;
height: $tape-row;
margin: 0;
overflow: hidden;
}
.cu__numcur,
.cu__tape i {
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 800;
line-height: $tape-row;
font-variant-numeric: tabular-nums;
}
.cu__numcur { position: absolute; inset: 0; }
.cu__tape {
display: none;
margin: 0;
padding: 0;
list-style: none;
}
.cu__tape i {
display: flex;
height: $tape-row;
font-style: normal;
}
.cu.is-demo .cu__tape { display: block; }
.cu.is-demo .cu__numcur { visibility: hidden; } // 미리보기 동안엔 테이프가 숫자를 보여준다
.cu__bar {
position: relative;
display: block;
height: 8px;
border-radius: $r-pill;
background: rgba($subject-ink, .12);
overflow: hidden;
}
.cu__barfill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 채움 막대 — 시작 변에서 자란다 (붙는 지점이 있는 요소 예외)
}
.cu__done {
justify-self: center;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $stage-orange;
color: $subject-ink;
font-size: 10px;
font-weight: 800;
line-height: 1.3;
opacity: 0;
transform: translateY(4px);
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.cu__done.is-shown {
opacity: 1;
transform: none;
}
.cu__more {
margin: $sp-2 0 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba($subject-ink, .72);
}
.cu__foot {
margin-top: $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;
color: rgba($subject-ink, .72);
}
// 미리보기 — 트랙이 카드를 밀어 올리고, 테이프가 0 부터 248 까지 easeOutCubic 곡선으로 흘러간다
// 테이프·막대는 이징 이동(유령 없음), 완료 배지는 컷 등장
.cu.is-demo {
.cu__track {
animation-name: cu-flow;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.cu__tape {
animation-name: cu-tape;
animation-duration: $duration;
animation-iteration-count: infinite;
}
.cu__barfill {
animation-name: cu-bar;
animation-duration: $duration;
animation-iteration-count: infinite;
}
.cu__done {
animation-name: cu-done-show, cu-done-pop;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
}
}
@keyframes cu-flow {
0%, 10% { transform: translateY(0); }
24%, 100% { transform: translateY(var(--cu-flow, -160px)); } // 카드 완전 진입 — 도달 상태로 끝까지, 재시작은 0% 점프
}
@keyframes cu-tape {
0%, 18% { transform: translateY(0); animation-timing-function: $easing; }
66%, 100% { transform: translateY($tape-row * -248); }
}
@keyframes cu-bar {
0%, 18% { transform: scaleX(0); animation-timing-function: $easing; }
66%, 100% { transform: scaleX(1); }
}
@keyframes cu-done-show {
0%, 66% { opacity: 0; }
67%, 100% { opacity: 1; }
}
@keyframes cu-done-pop {
0%, 66% { transform: translateY(4px); }
74%, 100% { transform: none; }
}
@media (max-width: 340px) {
.cu { --cu-flow: -116px; } // 좁은 뷰포트(118px) 에는 카드까지만 담긴다 — 아래 한 줄은 화면 밖에 둔다
.cu__head { padding: $sp-1 $sp-2; }
.cu__view { height: 118px; margin: 0 $sp-2; }
.cu__lead { margin-bottom: 96px; }
.cu__card { gap: $sp-1; padding: $sp-2 $sp-3; }
.cu__numcur, .cu__tape i { font-size: 26px; }
.cu__foot { margin-top: $sp-1; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cu__done { transition: none !important; }
.cu.is-demo .cu__track,
.cu.is-demo .cu__tape,
.cu.is-demo .cu__barfill,
.cu.is-demo .cu__done { animation: none !important; }
.cu.is-demo .cu__tape { display: none !important; }
.cu.is-demo .cu__numcur { 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);
}
.cu {
--cu-flow: -160px;
box-sizing: border-box;
width: min(400px, 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);
}
.cu__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.cu__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.3;
}
.cu__attr {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.cu__attr code {
font-family: "Consolas", "Menlo", monospace;
}
.cu__view {
box-sizing: border-box;
height: 170px;
overflow-y: auto;
margin: 0 12px;
padding: 0 8px;
outline: none;
background: rgba(23, 20, 26, 0.05);
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
}
.cu__track {
padding-top: 8px;
padding-bottom: 24px;
}
.cu__lead {
margin: 0 0 148px;
font-size: 11px;
font-weight: 700;
line-height: 14px;
color: rgba(23, 20, 26, 0.72);
}
.cu__card {
box-sizing: border-box;
display: grid;
gap: 8px;
padding: 12px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.04);
border: 1px solid rgba(23, 20, 26, 0.12);
text-align: center;
}
.cu__cap {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.cu__num {
position: relative;
height: 40px;
margin: 0;
overflow: hidden;
}
.cu__numcur,
.cu__tape i {
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 800;
line-height: 40px;
font-variant-numeric: tabular-nums;
}
.cu__numcur {
position: absolute;
inset: 0;
}
.cu__tape {
display: none;
margin: 0;
padding: 0;
list-style: none;
}
.cu__tape i {
display: flex;
height: 40px;
font-style: normal;
}
.cu.is-demo .cu__tape {
display: block;
}
.cu.is-demo .cu__numcur {
visibility: hidden;
}
.cu__bar {
position: relative;
display: block;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.cu__barfill {
position: absolute;
inset: 0;
border-radius: 999px;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
}
.cu__done {
justify-self: center;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #ff4d1f;
color: #17141a;
font-size: 10px;
font-weight: 800;
line-height: 1.3;
opacity: 0;
transform: translateY(4px);
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cu__done.is-shown {
opacity: 1;
transform: none;
}
.cu__more {
margin: 8px 0 0;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.cu__foot {
margin-top: 8px;
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);
}
.cu.is-demo .cu__track {
animation-name: cu-flow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.cu.is-demo .cu__tape {
animation-name: cu-tape;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.cu.is-demo .cu__barfill {
animation-name: cu-bar;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.cu.is-demo .cu__done {
animation-name: cu-done-show, cu-done-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes cu-flow {
0%, 10% {
transform: translateY(0);
}
24%, 100% {
transform: translateY(var(--cu-flow, -160px));
}
}
@keyframes cu-tape {
0%, 18% {
transform: translateY(0);
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
66%, 100% {
transform: translateY(-9920px);
}
}
@keyframes cu-bar {
0%, 18% {
transform: scaleX(0);
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
66%, 100% {
transform: scaleX(1);
}
}
@keyframes cu-done-show {
0%, 66% {
opacity: 0;
}
67%, 100% {
opacity: 1;
}
}
@keyframes cu-done-pop {
0%, 66% {
transform: translateY(4px);
}
74%, 100% {
transform: none;
}
}
@media (max-width: 340px) {
.cu {
--cu-flow: -116px;
}
.cu__head {
padding: 4px 8px;
}
.cu__view {
height: 118px;
margin: 0 8px;
}
.cu__lead {
margin-bottom: 96px;
}
.cu__card {
gap: 4px;
padding: 8px 12px;
}
.cu__numcur, .cu__tape i {
font-size: 26px;
}
.cu__foot {
margin-top: 4px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cu__done {
transition: none !important;
}
.cu.is-demo .cu__track,
.cu.is-demo .cu__tape,
.cu.is-demo .cu__barfill,
.cu.is-demo .cu__done {
animation: none !important;
}
.cu.is-demo .cu__tape {
display: none !important;
}
.cu.is-demo .cu__numcur {
visibility: visible !important;
}
}// 카운터 트리거 — 뷰포트 진입 순간 requestAnimationFrame 으로 0 부터 목표까지 올린다
// easing 은 easeOutCubic, 끝나면 unobserve 로 관찰을 멈춘다
var root = document.getElementById('cu');
var view = document.getElementById('cu-view');
var card = document.getElementById('cu-card');
var numEl = document.getElementById('cu-num');
var bar = document.getElementById('cu-bar');
var done = document.getElementById('cu-done');
var TARGET = 248;
var MS = 1400;
var started = false;
var REDUCE = matchMedia('(prefers-reduced-motion: reduce)').matches;
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function start() {
if (started) return;
started = true;
root.classList.remove('is-demo');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
io.unobserve(entry.target); // 여기서 관찰을 멈춘다 — 다시 올라와도 두 번 세지 않는다
if (REDUCE) { // reduce 에서는 세지 않고 목표값을 바로 찍는다 — 숫자가 올라가는 것도 움직임이다
numEl.textContent = String(TARGET);
bar.style.transform = 'scaleX(1)';
done.classList.add('is-shown');
return;
}
var t0 = null;
function tick(ts) {
if (t0 === null) t0 = ts;
var p = Math.min((ts - t0) / MS, 1);
var eased = easeOutCubic(p);
numEl.textContent = String(Math.round(TARGET * eased));
bar.style.transform = 'scaleX(' + eased + ')';
if (p < 1) requestAnimationFrame(tick);
else done.classList.add('is-shown');
}
requestAnimationFrame(tick);
});
}, { root: view, threshold: 0.6 });
io.observe(card);
}
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 "./CountUpOnce.module.scss";
export type CountUpOnceProps = {
title?: string;
attr?: string;
lead?: string;
cap?: string;
more?: string;
doneLabel?: string;
foot?: string;
groupLabel?: string;
viewLabel?: string;
target?: number;
ms?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--tape-shift"?: string;
};
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type Box<T> = { current: T };
type KeyEvt = { key: string };
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
export default function CountUpOnce({
title = "카운터 트리거",
attr = "requestAnimationFrame",
lead = "아래로 스크롤하면 숫자가 셉니다",
cap = "완료한 프로젝트",
more = "계속 내려도 더 없습니다",
doneLabel = "카운트 완료 — 관찰 종료",
foot = "다시 올라와도 두 번 세지 않습니다",
groupLabel = "카운터 트리거",
viewLabel = "실적 블록 — 스크롤해 보세요",
target = 248,
ms = 1400,
demo = true,
duration = 2000,
easing = "cubic-bezier(.215, .61, .355, 1)",
color = "#2f6df6",
}: CountUpOnceProps) {
const [live, setLive] = useState(demo);
const [done, setDone] = useState(false);
const viewRef = useRef<HTMLDivElement>(null) as unknown as Box<HTMLDivElement | null>;
const cardRef = useRef<HTMLElement>(null) as unknown as Box<HTMLElement | null>;
const numRef = useRef<HTMLElement>(null) as unknown as Box<HTMLElement | null>;
const barRef = useRef<HTMLElement>(null) as unknown as Box<HTMLElement | null>;
useEffect(() => {
if (live) return;
const view = viewRef.current;
const card = cardRef.current;
if (!view || !card) return;
let raf = 0;
// reduce 에서는 세지 않고 목표값을 바로 찍는다 — 숫자가 올라가는 것도 움직임이다
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
// 진입 순간 requestAnimationFrame 으로 0 부터 목표까지 — 끝나면 unobserve 로 두 번 세지 않는다
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
io.unobserve(entry.target); // 여기서 관찰을 멈춘다
if (reduce) {
if (numRef.current) numRef.current.textContent = String(target);
if (barRef.current) barRef.current.style.transform = "scaleX(1)";
setDone(true);
return;
}
let t0: number | null = null;
const tick = (ts: number) => {
if (t0 === null) t0 = ts;
const p = Math.min((ts - t0) / ms, 1);
const eased = easeOutCubic(p);
if (numRef.current) numRef.current.textContent = String(Math.round(target * eased));
if (barRef.current) barRef.current.style.transform = "scaleX(" + eased + ")";
if (p < 1) raf = requestAnimationFrame(tick);
else setDone(true);
};
raf = requestAnimationFrame(tick);
});
},
{ root: view, threshold: 0.6 }
);
io.observe(card);
return () => {
io.disconnect();
cancelAnimationFrame(raf);
};
}, [live, target, ms]);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--tape-shift": `${-target * 40}px`, // 테이프 마지막 칸(목표 숫자) 까지의 이동거리 — 칸 높이 40px
};
const rows =
live && target >= 0 && target < 1000
? Array.from({ length: target + 1 }, (_, i) => i)
: [];
return (
<section
className={`${styles.cu} ${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={viewLabel}
>
<div className={styles.track}>
<p className={styles.lead}>{lead}</p>
<article
className={styles.card}
ref={cardRef as unknown as Box<HTMLElement>}
>
<p className={styles.cap}>{cap}</p>
<p className={styles.num}>
{live ? (
<span className={styles.tape} aria-hidden="true">
{rows.map((i) => (<i key={i}>{i}</i>))}
</span>
) : (
<b
className={styles.numcur}
ref={numRef as unknown as Box<HTMLElement>}
>
0
</b>
)}
</p>
<span className={styles.bar} aria-hidden="true">
<i
className={styles.barfill}
ref={barRef as unknown as Box<HTMLElement>}
/>
</span>
<p className={`${styles.done} ${done ? styles.isShown : ""}`}>{doneLabel}</p>
</article>
<p className={styles.more}>{more}</p>
</div>
</div>
<footer className={styles.foot}>{foot}</footer>
</section>
);
}04 카운터 트리거 — 측정 누적면적 13.421% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로
이 데모가 속한 글
스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로스크롤 트리거 애니메이션이란 화면을 내리다가 요소가 뷰포트에 들어오는 순간 움직임을 시작하게 하는 장치입니다. GSAP 없이 IntersectionObserver 하나로 아홉 개의 엔진을 직접 만들었고, 아래 격자에 이 글의 데모 전부 보기


