09사례 푸터 — 지금까지 본 사례들의 평균을 직접 구하는 요약 배지
지금까지 살펴본 사례들의 완료 기간이나 성과 수치를 모아 평균을 직접 구해 마지막 배지에 요약으로 보여주고, 새 사례가 더해지면 그 평균을 다시 계산합니다.
쓰임사례 연구 페이지 하단 요약 섹션, CTA 앞 마무리 배지
classList.addsetIntervaltransform-origin

<main class="stage">
<section class="panel">
<p class="hint" id="hint">새 열람이 들어올 때마다 평균을 처음부터 다시 나누지 않고, 누적합·누적건수만 갱신해 평균을 계산합니다</p>
<div class="footerBar">
<div class="stat">
<p class="statNum" id="countNum">0</p>
<p class="statLabel">건 열람</p>
</div>
<div class="divider" aria-hidden="true"></div>
<div class="stat">
<p class="statNum" id="avgNum">0.0</p>
<p class="statLabel">평균 임팩트 점수</p>
</div>
</div>
<p class="feedNote" id="feedNote">최근: —</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue-light;
@include stage($stage-ink);
.panel {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.hint {
margin: 0;
text-align: center;
color: rgba($subject-cream, .72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.footerBar {
border-radius: $r-control;
background: rgba($subject-cream, .08);
padding: $sp-3 $sp-4;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-4;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
min-width: 84px;
}
.statNum {
margin: 0;
color: $subject-cream;
font-weight: 700;
font-size: 22px;
font-variant-numeric: tabular-nums;
transform-origin: center;
}
.statNum--bump {
animation: bump $duration $easing;
}
@keyframes bump {
0% { transform: scale(1); }
40% { transform: scale(1.16); color: $color; }
100% { transform: scale(1); }
}
.statLabel {
margin: 0;
color: rgba($subject-cream, .72);
font-weight: 500;
font-size: 9px;
}
.divider {
width: 1px;
align-self: stretch;
background: rgba($subject-cream, .18);
}
.feedNote {
margin: 0;
text-align: center;
color: $color;
font-weight: 600;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.panel { max-width: 280px; gap: $sp-1; }
.footerBar { padding: $sp-2 $sp-3; gap: $sp-3; }
.stat { min-width: 68px; }
.statNum { font-size: 18px; }
.hint { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.statNum--bump {
animation: none;
}
}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);
}
.panel {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hint {
margin: 0;
text-align: center;
color: rgba(255, 247, 230, 0.72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.footerBar {
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 84px;
}
.statNum {
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: 22px;
font-variant-numeric: tabular-nums;
transform-origin: center;
}
.statNum--bump {
animation: bump 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.16);
color: #5b8cf8;
}
100% {
transform: scale(1);
}
}
.statLabel {
margin: 0;
color: rgba(255, 247, 230, 0.72);
font-weight: 500;
font-size: 9px;
}
.divider {
width: 1px;
align-self: stretch;
background: rgba(255, 247, 230, 0.18);
}
.feedNote {
margin: 0;
text-align: center;
color: #5b8cf8;
font-weight: 600;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.panel {
max-width: 280px;
gap: 4px;
}
.footerBar {
padding: 8px 12px;
gap: 12px;
}
.stat {
min-width: 68px;
}
.statNum {
font-size: 18px;
}
.hint {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.statNum--bump {
animation: none;
}
}// 사례 푸터(09) — 새 열람 이벤트가 들어올 때마다 평균을 처음부터 다시 나누지 않고,
// 누적합(sum)과 누적건수(count)만 갱신해 running average 를 계산한다(계산 한 겹).
(function () {
var countNum = document.getElementById('countNum');
var avgNum = document.getElementById('avgNum');
var feedNote = document.getElementById('feedNote');
if (!countNum || !avgNum || !feedNote) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 가상 열람 이벤트: 임팩트 점수(0~100)가 하나씩 도착한다.
var EVENTS = [72, 55, 88, 61, 94, 47, 76, 83, 59, 68, 91, 64];
var sum = 0;
var count = 0;
var idx = 0;
function step() {
if (idx >= EVENTS.length) idx = 0;
var value = EVENTS[idx];
sum += value;
count += 1;
idx += 1;
var average = sum / count;
countNum.textContent = String(count);
avgNum.textContent = average.toFixed(1);
feedNote.textContent = '최근: 임팩트 ' + value + '점 열람';
if (!reduce) {
countNum.classList.remove('statNum--bump');
avgNum.classList.remove('statNum--bump');
// 리플로우를 강제해 같은 클래스를 다시 걸어도 애니메이션이 재시작되게 한다.
void countNum.offsetWidth;
countNum.classList.add('statNum--bump');
avgNum.classList.add('statNum--bump');
}
}
step();
if (!reduce) {
setInterval(step, 550);
}
})();// CaseFooter — vanilla 판(run/340/demo/09-case-footer/index.html) 의 React 포트.
// 새 열람 이벤트가 들어올 때마다 평균을 처음부터 다시 나누지 않고, 누적합(sum)과
// 누적건수(count)만 갱신해 running average 를 계산한다.
import { useEffect, useRef, useState } from "react";
import styles from "./CaseFooter.module.scss";
export type CaseFooterProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "새 열람이 들어올 때마다 평균을 처음부터 다시 나누지 않고, 누적합·누적건수만 갱신해 평균을 계산합니다";
const EVENTS = [72, 55, 88, 61, 94, 47, 76, 83, 59, 68, 91, 64];
export default function CaseFooter({
duration = "300ms",
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#5b8cf8"
}: CaseFooterProps) {
const [count, setCount] = useState(0);
const [average, setAverage] = useState(0);
const [feedNote, setFeedNote] = useState("최근: —");
const [bump, setBump] = useState(false);
const sumRef = useRef(0);
const countRef = useRef(0);
const idxRef = useRef(0);
const reduceRef = useRef(false);
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function step(): void {
if (idxRef.current! >= EVENTS.length) idxRef.current = 0;
const value = EVENTS[idxRef.current!];
sumRef.current = sumRef.current! + value;
countRef.current = countRef.current! + 1;
idxRef.current = idxRef.current! + 1;
const nextAverage = sumRef.current! / countRef.current!;
setCount(countRef.current!);
setAverage(nextAverage);
setFeedNote("최근: 임팩트 " + value + "점 열람");
if (!reduceRef.current) {
setBump(false);
requestAnimationFrame(() => setBump(true));
}
}
step();
if (reduceRef.current) return;
const timer = window.setInterval(step, 550);
return () => window.clearInterval(timer);
}, []);
const bumpClass = bump ? " " + styles.statNumBump : "";
return (
<main className={styles.stage}>
<section className={styles.panel}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.footerBar}>
<div className={styles.stat}>
<p className={styles.statNum + bumpClass}>{count}</p>
<p className={styles.statLabel}>건 열람</p>
</div>
<div className={styles.divider} aria-hidden="true" />
<div className={styles.stat}>
<p className={styles.statNum + bumpClass}>{average.toFixed(1)}</p>
<p className={styles.statLabel}>평균 임팩트 점수</p>
</div>
</div>
<p className={styles.feedNote}>{feedNote}</p>
</section>
</main>
);
}09 사례 푸터 — 지금까지 본 사례들의 평균을 직접 구하는 요약 배지 — 측정 누적면적 0.79% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 케이스 스터디 사이트 UI 9종 — 성과 수치까지 계산해 보여주는
이 데모가 속한 글
케이스 스터디 사이트 UI 9종 — 성과 수치까지 계산해 보여주는화면에 들어오는 순간 성과 수치가 매 프레임 다시 구해지며 카운트업되고, 사례마다 계산한 임팩트 점수로 그리드 타일 크기가 정해지며, 인용문 길이에 맞춰 폰트 크기가 거꾸로 구해지는 사례 연구 페이지 섹션 9종입니다 이 글의 데모 전부 보기


