01Case hero — a top section that recomputes its headline metric every frame to count it up
As soon as the section enters view, the headline metric gets recomputed frame by frame from zero up to its target, rolling the digits up naturally while any extra digit position is worked out to match the value.
Best forAgency case-study page heroes, client results highlight sections
getContext('2d')measureTextrequestAnimationFrame

<main class="stage">
<section class="hero" id="hero">
<p class="eyebrow" id="eyebrow">Project Nova · Case Study</p>
<p class="metricLabel" id="metricLabel">Key metric</p>
<p class="metricRow">
<span class="metricNum" id="metricNum">0</span><span class="metricUnit" id="metricUnit">%</span>
</p>
<p class="hint" id="hint">While you stay on screen, the key metric is recalculated from 0 every frame and counted up.</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.hero {
position: relative;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
text-align: center;
}
.eyebrow {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba($subject-cream, .12);
color: $subject-cream;
font-weight: 500;
font-size: 10px;
letter-spacing: .04em;
}
.metricLabel {
margin: 0;
color: rgba($subject-cream, .72);
font-weight: 500;
font-size: 12px;
}
.metricRow {
position: relative;
margin: 0;
display: flex;
align-items: baseline;
justify-content: center;
gap: $sp-1;
padding: $sp-2 $sp-6;
}
.metricRow::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 120px;
height: 120px;
border-radius: $r-pill;
background: radial-gradient(circle, rgba($color, .38), rgba($color, 0) 70%);
animation: halo $duration $easing infinite;
z-index: 0;
}
.metricNum {
--num-w: 88px;
position: relative;
z-index: 1;
display: inline-block;
min-width: var(--num-w);
text-align: right;
font-weight: 700;
font-size: 44px;
font-variant-numeric: tabular-nums;
color: $subject-cream;
}
.metricUnit {
position: relative;
z-index: 1;
font-weight: 700;
font-size: 22px;
color: $color;
}
.hint {
margin: 0;
padding: 0 $sp-2;
color: rgba($subject-cream, .72);
font-weight: 400;
font-size: 11px;
line-height: 1.4;
}
@keyframes halo {
0%, 100% { transform: scale(.86); opacity: .5; }
50% { transform: scale(1.12); opacity: 1; }
}
@media (max-width: 340px) {
.hero { max-width: 300px; gap: $sp-1; }
.metricNum { font-size: 32px; }
.metricUnit { font-size: 16px; }
.hint { font-size: 9px; }
.metricRow { padding: $sp-1 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.metricRow::before {
animation: none;
opacity: .7;
}
}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);
}
.hero {
position: relative;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.eyebrow {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 500;
font-size: 10px;
letter-spacing: 0.04em;
}
.metricLabel {
margin: 0;
color: rgba(255, 247, 230, 0.72);
font-weight: 500;
font-size: 12px;
}
.metricRow {
position: relative;
margin: 0;
display: flex;
align-items: baseline;
justify-content: center;
gap: 4px;
padding: 8px 24px;
}
.metricRow::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 120px;
height: 120px;
border-radius: 999px;
background: radial-gradient(circle, rgba(10, 79, 232, 0.38), rgba(10, 79, 232, 0) 70%);
animation: halo 2s ease-out infinite;
z-index: 0;
}
.metricNum {
--num-w: 88px;
position: relative;
z-index: 1;
display: inline-block;
min-width: var(--num-w);
text-align: right;
font-weight: 700;
font-size: 44px;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.metricUnit {
position: relative;
z-index: 1;
font-weight: 700;
font-size: 22px;
color: #0a4fe8;
}
.hint {
margin: 0;
padding: 0 8px;
color: rgba(255, 247, 230, 0.72);
font-weight: 400;
font-size: 11px;
line-height: 1.4;
}
@keyframes halo {
0%, 100% {
transform: scale(0.86);
opacity: 0.5;
}
50% {
transform: scale(1.12);
opacity: 1;
}
}
@media (max-width: 340px) {
.hero {
max-width: 300px;
gap: 4px;
}
.metricNum {
font-size: 32px;
}
.metricUnit {
font-size: 16px;
}
.hint {
font-size: 9px;
}
.metricRow {
padding: 4px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.metricRow::before {
animation: none;
opacity: 0.7;
}
}// 사례 히어로(01) — 값을 누적하지 않고, 매 애니메이션 프레임마다 경과 비율(t)로부터
// 목표값까지의 값을 공식(eased(t) * target)으로 처음부터 다시 구해 카운트업한다(계산 한 겹).
// 자릿수가 늘어나는 순간에는 canvas measureText 로 그 시점 문자열의 실제 렌더 폭을 다시 재
// 숫자 칸의 폭(--num-w)을 갱신해, 다음 문자가 밀리지 않고 자리에 맞춰 넓어지게 한다.
(function () {
var numEl = document.getElementById('metricNum');
var wrapEl = numEl ? numEl.parentElement : null;
if (!numEl || !wrapEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var TARGET = 128;
var EASE_POW = 2; // 변형: 2 → 4
var ANIM_MS = 1300;
var CYCLE_MS = 1900;
var measureCv = document.createElement('canvas');
var measureCtx = measureCv.getContext('2d');
function measureWidth(text, px) {
if (!measureCtx) return text.length * px * 0.62;
measureCtx.font = '700 ' + px + 'px -apple-system, "Segoe UI", sans-serif';
return measureCtx.measureText(text).width;
}
var lastDigits = -1;
function applyValue(v) {
numEl.textContent = String(v);
var digits = String(v).length;
if (digits !== lastDigits) {
lastDigits = digits;
var px = parseFloat(getComputedStyle(numEl).fontSize) || 44;
var w = measureWidth(String(v), px);
wrapEl.style.setProperty('--num-w', Math.ceil(w) + 'px');
}
}
function runCycle() {
if (reduce) { applyValue(TARGET); return; }
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min((ts - start) / ANIM_MS, 1);
var eased = 1 - Math.pow(1 - t, EASE_POW);
var val = Math.round(TARGET * eased);
applyValue(val);
if (t < 1) requestAnimationFrame(frame);
}
applyValue(0);
requestAnimationFrame(frame);
}
runCycle();
if (!reduce) setInterval(runCycle, CYCLE_MS);
})();// CaseHero — vanilla 판(run/340/demo/01-case-hero/index.html) 의 React 포트.
// 값을 누적하지 않고 매 프레임 경과 비율(t)로부터 목표값까지의 값을 공식으로 처음부터 다시 구해
// 카운트업하고, 자릿수가 늘어나는 순간엔 canvas measureText 로 실제 렌더 폭을 다시 재 숫자 칸 폭을 갱신한다.
import { useEffect, useRef } from "react";
import styles from "./CaseHero.module.scss";
export type CaseHeroProps = {
duration?: string;
easing?: string;
color?: string;
};
const EYEBROW = "프로젝트 노바 · 사례 연구";
const METRIC_LABEL = "핵심 성과";
const HINT = "화면에 머무는 동안 핵심 성과 수치를 0부터 매 프레임 다시 구해 카운트업합니다";
const TARGET = 128;
const EASE_POW = 2;
const ANIM_MS = 1300;
const CYCLE_MS = 1900;
export default function CaseHero({
duration = "2s",
easing = "ease-out",
color = "#0a4fe8"
}: CaseHeroProps) {
const numRef = useRef<HTMLSpanElement | null>(null);
const wrapRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const numEl = numRef.current;
const wrapEl = wrapRef.current;
if (!numEl || !wrapEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const measureCv = document.createElement("canvas");
const measureCtx = measureCv.getContext("2d");
function measureWidth(text: string, px: number): number {
if (!measureCtx) return text.length * px * 0.62;
measureCtx.font = "700 " + px + 'px -apple-system, "Segoe UI", sans-serif';
return measureCtx.measureText(text).width;
}
let lastDigits = -1;
function applyValue(v: number): void {
numEl!.textContent = String(v);
const digits = String(v).length;
if (digits !== lastDigits) {
lastDigits = digits;
const px = parseFloat(getComputedStyle(numEl!).fontSize) || 44;
const w = measureWidth(String(v), px);
wrapEl!.style.setProperty("--num-w", Math.ceil(w) + "px");
}
}
function runCycle(): void {
if (reduce) { applyValue(TARGET); return; }
let start: number | null = null;
function frame(ts: number): void {
if (start === null) start = ts;
const t = Math.min((ts - start) / ANIM_MS, 1);
const eased = 1 - Math.pow(1 - t, EASE_POW);
const val = Math.round(TARGET * eased);
applyValue(val);
if (t < 1) requestAnimationFrame(frame);
}
applyValue(0);
requestAnimationFrame(frame);
}
runCycle();
let timer = 0;
if (!reduce) timer = window.setInterval(runCycle, CYCLE_MS);
return () => {
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<section className={styles.hero}>
<p className={styles.eyebrow}>{EYEBROW}</p>
<p className={styles.metricLabel}>{METRIC_LABEL}</p>
<p className={styles.metricRow} ref={wrapRef}>
<span className={styles.metricNum} ref={numRef}>0</span>
<span className={styles.metricUnit}>%</span>
</p>
<p className={styles.hint}>{HINT}</p>
</section>
</main>
);
}01 Case hero — a top section that recomputes its headline metric every frame to count it up — Measured area 6.476% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · 9 Case Study Page Design Sections (Copy-Paste)
The post this demo lives in
9 Case Study Page Design Sections (Copy-Paste)Nine case-study sections that compute: the hero recounts its metric frame by frame, tiles size from a computed impact sc See all demos in this post
If you like this effect - similar demos
Landing sections01 Plan cards — a card row that recomputes its price on every billing switch9 Pricing Page Design Sections (Copy-Paste)
Landing sections05 Team stat row — a count-up that runs elapsed-time ratio through an easing function9 Team Page Design Patterns (Copy-Paste)
Landing sections09 Stat counter — a number recalculated every frame up to a target valueHero Section Design Examples: 9 Patterns