03결과 개수 모프 — 이전 값과 새 값 사이를 보간해 세는 숫자
필터 조건이 바뀌어 결과 개수가 달라진 상태가 되면 이전 개수 값에서 새 개수 값까지 일정 간격으로 나눠 하나씩 올리거나 내리며 표시합니다.
쓰임상품 목록 상단의 'N개 결과' 표시, 대시보드 필터 요약 카드
requestAnimationFramesetPropertytextContent

<main class="stage">
<div class="card">
<p class="card__label">검색 결과</p>
<p class="card__count" id="count" role="status" aria-live="polite">128개</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.card {
box-sizing: border-box;
min-width: 180px;
padding: $sp-6 $sp-8;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
text-align: center;
}
.card__label {
margin: 0 0 $sp-2;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
}
.card__count {
margin: 0;
color: $subject-ink;
font-weight: 700;
font-size: clamp(28px, 8vmin, 40px);
line-height: 1.1;
font-variant-numeric: tabular-nums;
--pulse: 0;
transform: scale(calc(1 + var(--pulse, 0) * .05));
}
@media (max-width: 340px) {
.card { padding: $sp-4 $sp-6; min-width: 140px; }
}
@media (prefers-reduced-motion: reduce) {
.card__count { transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.card {
box-sizing: border-box;
min-width: 180px;
padding: 24px 32px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
text-align: center;
}
.card__label {
margin: 0 0 8px;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
}
.card__count {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(28px, 8vmin, 40px);
line-height: 1.1;
font-variant-numeric: tabular-nums;
--pulse: 0;
transform: scale(calc(1 + var(--pulse, 0) * 0.05));
}
@media (max-width: 340px) {
.card {
padding: 16px 24px;
min-width: 140px;
}
}
@media (prefers-reduced-motion: reduce) {
.card__count {
transform: none;
}
}// 결과 수가 바뀔 때 이전 값과 새 값의 차이를 requestAnimationFrame 으로 나눠 화면 숫자를 그 사이 값으로 다시 그린다(textContent).
// 자동 시연만, 실사용 리스너 없음.
(function () {
var el = document.getElementById('count');
if (!el) return;
var VALUES = [128, 47, 283, 128];
var STEP_MS = 900;
var HOLD_MS = 350;
var idx = 0;
var from = VALUES[0];
function ease(t) { return 1 - Math.pow(1 - t, 3); }
function animate(to, duration) {
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / duration);
var value = Math.round(from + (to - from) * ease(t));
el.textContent = value + '개';
el.style.setProperty('--pulse', (1 - t).toFixed(3));
if (t < 1) {
requestAnimationFrame(frame);
} else {
from = to;
setTimeout(step, HOLD_MS);
}
}
requestAnimationFrame(frame);
}
function step() {
idx = (idx + 1) % VALUES.length;
animate(VALUES[idx], STEP_MS);
}
setTimeout(step, HOLD_MS);
})();import { useEffect, useRef } from "react";
import styles from "./ResultCountMorph.module.scss";
// 결과 수가 바뀔 때 이전 값과 새 값의 차이를 requestAnimationFrame 으로 나눠 화면 숫자를 그 사이 값으로 다시 그린다(textContent).
// 자동 시연만, 실사용 리스너 없음.
const VALUES = [128, 47, 283, 128];
const STEP_MS = 900;
const HOLD_MS = 350;
function ease(t: number) {
return 1 - Math.pow(1 - t, 3);
}
export default function ResultCountMorph() {
const countRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const el = countRef.current;
if (!el) return;
let alive = true;
let idx = 0;
let from = VALUES[0];
let rafId = 0;
let timeoutId = 0;
function animate(to: number, duration: number) {
let start: number | null = null;
function frame(ts: number) {
if (!alive) return;
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / duration);
const value = Math.round(from + (to - from) * ease(t));
el!.textContent = value + "개";
el!.style.setProperty("--pulse", (1 - t).toFixed(3));
if (t < 1) {
rafId = requestAnimationFrame(frame);
} else {
from = to;
timeoutId = window.setTimeout(step, HOLD_MS);
}
}
rafId = requestAnimationFrame(frame);
}
function step() {
if (!alive) return;
idx = (idx + 1) % VALUES.length;
animate(VALUES[idx], STEP_MS);
}
timeoutId = window.setTimeout(step, HOLD_MS);
return () => {
alive = false;
cancelAnimationFrame(rafId);
window.clearTimeout(timeoutId);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.card}>
<p className={styles.card__label}>검색 결과</p>
<p ref={countRef} className={styles.card__count} role="status" aria-live="polite">
128개
</p>
</div>
</main>
);
}03 결과 개수 모프 — 이전 값과 새 값 사이를 보간해 세는 숫자 — 측정 누적면적 1.142% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 필터 UI 9종 — 패널 슬라이드부터 칩 제거까지
이 데모가 속한 글
필터 UI 9종 — 패널 슬라이드부터 칩 제거까지필터 UI 아홉 개 중 하나는 칩이 사라진 만큼 남은 칩들의 폭 합계를 다시 구해 자리를 좁히고, 하나는 활성 필터 개수 값이 바뀔 때마다 배지 크기를 그 차이만큼 부풀렸다 되돌리며, 또 하나는 두 슬라이더 값의 차 이 글의 데모 전부 보기


