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 オリジナル · SCSS と React は zip に同梱 · 導入事例 web デザイン9選 — 成果数値まで計算して見せる
このデモが属する記事
導入事例 web デザイン9選 — 成果数値まで計算して見せる導入事例 web デザインは色や余白より先に、数字がどう動くかで差がつきます。画面に入った瞬間から成果数値がフレームごとに求め直されカウントアップし、事例ごとに計算したインパクトスコアでグリッドタイルの大きさが決まり、引用文の長さに合わせて この記事のデモをすべて見る


