03메트릭 하이라이트 — 이전·이후 비율을 직접 나눠 구하는 강조 카드
이전 값과 이후 값의 비율을 직접 나눠 계산해 게이지 바늘의 각도나 막대 길이를 그 비율에 맞춰 구하고, 비율이 클수록 강조 색을 진하게 바꿉니다.
쓰임성과 지표 강조 카드, before/after 수치 배지
stroke-dashoffsetrequestAnimationFramefont-variant-numeric

<main class="stage">
<section class="panel" id="panel">
<p class="hint" id="hint">전후 수치의 개선폭을 직접 나눠 비율을 구하고, 그 비율만큼 원 둘레를 계산해 게이지를 채웁니다</p>
<div class="gaugeWrap">
<svg class="gaugeSvg" width="132" height="132" viewBox="0 0 132 132" aria-hidden="true">
<circle class="gaugeTrack" cx="66" cy="66" r="54"></circle>
<circle class="gaugeFill" id="gaugeFill" cx="66" cy="66" r="54"></circle>
</svg>
<div class="gaugeCenter">
<span class="gaugePercent" id="gaugePercent">0%</span>
<span class="gaugeCaption" id="gaugeCaption">개선</span>
</div>
</div>
<p class="metricName" id="metricName">응답 시간</p>
<p class="metricPair">
<span class="metricBefore" id="metricBefore">420ms</span>
<span class="metricArrow" aria-hidden="true">→</span>
<span class="metricAfter" id="metricAfter">420ms</span>
</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-orange);
.panel {
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
text-align: center;
}
.hint {
margin: 0;
color: rgba($subject-ink, .72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.gaugeWrap {
position: relative;
width: 132px;
height: 132px;
}
.gaugeSvg {
transform: rotate(-90deg);
}
.gaugeTrack {
fill: none;
stroke: rgba($subject-ink, .14);
stroke-width: 10px;
}
.gaugeFill {
fill: none;
stroke: $color;
stroke-width: 10px;
stroke-linecap: round;
transition: stroke-dashoffset $duration $easing;
}
.gaugeCenter {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
}
.gaugePercent {
font-weight: 700;
font-size: 26px;
color: $subject-ink;
font-variant-numeric: tabular-nums;
}
.gaugeCaption {
font-weight: 500;
font-size: 10px;
color: rgba($subject-ink, .72);
}
.metricName {
margin: 0;
font-weight: 600;
font-size: 13px;
color: $subject-ink;
}
.metricPair {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.metricBefore {
color: rgba($subject-ink, .72);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.metricArrow {
color: rgba($subject-ink, .72);
font-weight: 400;
}
.metricAfter {
color: $color;
}
@media (max-width: 340px) {
.panel { max-width: 260px; gap: $sp-1; }
.gaugeWrap { width: 84px; height: 84px; }
.gaugePercent { font-size: 17px; }
.gaugeCaption { font-size: 8px; }
.metricName { font-size: 11px; }
.metricPair { font-size: 11px; gap: $sp-1; }
.hint { font-size: 8px; line-height: 1.25; }
}
@media (prefers-reduced-motion: reduce) {
.gaugeFill {
transition: 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);
}
.panel {
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.gaugeWrap {
position: relative;
width: 132px;
height: 132px;
}
.gaugeSvg {
transform: rotate(-90deg);
}
.gaugeTrack {
fill: none;
stroke: rgba(23, 20, 26, 0.14);
stroke-width: 10px;
}
.gaugeFill {
fill: none;
stroke: #0a4fe8;
stroke-width: 10px;
stroke-linecap: round;
transition: stroke-dashoffset 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gaugeCenter {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
}
.gaugePercent {
font-weight: 700;
font-size: 26px;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.gaugeCaption {
font-weight: 500;
font-size: 10px;
color: rgba(23, 20, 26, 0.72);
}
.metricName {
margin: 0;
font-weight: 600;
font-size: 13px;
color: #17141a;
}
.metricPair {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.metricBefore {
color: rgba(23, 20, 26, 0.72);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
.metricArrow {
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
}
.metricAfter {
color: #0a4fe8;
}
@media (max-width: 340px) {
.panel {
max-width: 260px;
gap: 4px;
}
.gaugeWrap {
width: 84px;
height: 84px;
}
.gaugePercent {
font-size: 17px;
}
.gaugeCaption {
font-size: 8px;
}
.metricName {
font-size: 11px;
}
.metricPair {
font-size: 11px;
gap: 4px;
}
.hint {
font-size: 8px;
line-height: 1.25;
}
}
@media (prefers-reduced-motion: reduce) {
.gaugeFill {
transition: none;
}
}// 메트릭 하이라이트(03) — 전/후 두 수치만 데이터로 두고, 개선율을 매 사이클 직접 나눠 구한 뒤
// 원 둘레(2*PI*R)를 계산해 그 비율만큼 stroke-dashoffset 을 역산한다. 게이지 값은 어디에도
// 미리 적어두지 않고, 전/후 수치로부터 매번 다시 계산된다(계산 한 겹).
(function () {
var fill = document.getElementById('gaugeFill');
var percentEl = document.getElementById('gaugePercent');
var captionEl = document.getElementById('gaugeCaption');
var nameEl = document.getElementById('metricName');
var beforeEl = document.getElementById('metricBefore');
var afterEl = document.getElementById('metricAfter');
if (!fill || !percentEl || !nameEl || !beforeEl || !afterEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var RADIUS = 54;
var CIRC = 2 * Math.PI * RADIUS;
fill.style.strokeDasharray = String(CIRC);
var METRICS = [
{ label: '응답 시간', before: 420, after: 180, unit: 'ms', lowerIsBetter: true, caption: '개선' },
{ label: '전환율', before: 2.1, after: 5.8, unit: '%', lowerIsBetter: false, caption: '상승' },
{ label: '이탈률', before: 61, after: 34, unit: '%', lowerIsBetter: true, caption: '개선' }
];
var MAX_IMPROVEMENT = 0.7; // 게이지가 항상 꽉 차지 않도록 잡은 정규화 상한
var ANIM_MS = 1100;
var CYCLE_MS = 2200;
function computeRatio(metric) {
var delta = metric.lowerIsBetter ? (metric.before - metric.after) : (metric.after - metric.before);
var improvement = delta / metric.before;
return Math.max(0, Math.min(1, improvement / MAX_IMPROVEMENT));
}
function computePercent(metric) {
var delta = metric.lowerIsBetter ? (metric.before - metric.after) : (metric.after - metric.before);
return Math.round((delta / metric.before) * 100);
}
function applyGauge(ratio) {
var offset = CIRC * (1 - ratio);
fill.style.strokeDashoffset = String(offset);
}
function fmt(metric, value) {
if (metric.unit === 'ms') return Math.round(value) + 'ms';
return (Math.round(value * 10) / 10) + '%';
}
var idx = 0;
function runMetric(metric) {
nameEl.textContent = metric.label;
captionEl.textContent = metric.caption;
beforeEl.textContent = fmt(metric, metric.before);
var targetPercent = computePercent(metric);
var targetRatio = computeRatio(metric);
if (reduce) {
afterEl.textContent = fmt(metric, metric.after);
percentEl.textContent = targetPercent + '%';
applyGauge(targetRatio);
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, 3);
var curAfter = metric.before + (metric.after - metric.before) * eased;
var curRatio = targetRatio * eased;
var curPercent = Math.round(targetPercent * eased);
afterEl.textContent = fmt(metric, curAfter);
percentEl.textContent = curPercent + '%';
applyGauge(curRatio);
if (t < 1) requestAnimationFrame(frame);
}
applyGauge(0);
percentEl.textContent = '0%';
afterEl.textContent = fmt(metric, metric.before);
requestAnimationFrame(frame);
}
runMetric(METRICS[0]);
if (!reduce) {
setInterval(function () {
idx = (idx + 1) % METRICS.length;
runMetric(METRICS[idx]);
}, CYCLE_MS);
}
})();// CaseMetricHighlight — vanilla 판(run/340/demo/03-case-metric-highlight/index.html) 의 React 포트.
// 전/후 수치만 데이터로 두고 개선율을 매 사이클 직접 나눠 구한 뒤, 원 둘레(2*PI*R)를 계산해
// 그 비율만큼 stroke-dashoffset 을 역산한다. 게이지 값은 미리 적어두지 않고 매번 다시 계산한다.
import { useEffect, useRef } from "react";
import styles from "./CaseMetricHighlight.module.scss";
export type CaseMetricHighlightProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "전후 수치의 개선폭을 직접 나눠 비율을 구하고, 그 비율만큼 원 둘레를 계산해 게이지를 채웁니다";
type Metric = {
label: string;
before: number;
after: number;
unit: "ms" | "%";
lowerIsBetter: boolean;
caption: string;
};
const METRICS: Metric[] = [
{ label: "응답 시간", before: 420, after: 180, unit: "ms", lowerIsBetter: true, caption: "개선" },
{ label: "전환율", before: 2.1, after: 5.8, unit: "%", lowerIsBetter: false, caption: "상승" },
{ label: "이탈률", before: 61, after: 34, unit: "%", lowerIsBetter: true, caption: "개선" }
];
const RADIUS = 54;
const CIRC = 2 * Math.PI * RADIUS;
const MAX_IMPROVEMENT = 0.7;
const ANIM_MS = 1100;
const CYCLE_MS = 2200;
function computeRatio(metric: Metric): number {
const delta = metric.lowerIsBetter ? metric.before - metric.after : metric.after - metric.before;
const improvement = delta / metric.before;
return Math.max(0, Math.min(1, improvement / MAX_IMPROVEMENT));
}
function computePercent(metric: Metric): number {
const delta = metric.lowerIsBetter ? metric.before - metric.after : metric.after - metric.before;
return Math.round((delta / metric.before) * 100);
}
function fmt(metric: Metric, value: number): string {
if (metric.unit === "ms") return Math.round(value) + "ms";
return Math.round(value * 10) / 10 + "%";
}
export default function CaseMetricHighlight({
duration = "600ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8"
}: CaseMetricHighlightProps) {
const fillRef = useRef<SVGCircleElement | null>(null);
const percentRef = useRef<HTMLSpanElement | null>(null);
const captionRef = useRef<HTMLSpanElement | null>(null);
const nameRef = useRef<HTMLParagraphElement | null>(null);
const beforeRef = useRef<HTMLSpanElement | null>(null);
const afterRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const fill = fillRef.current;
const percentEl = percentRef.current;
const captionEl = captionRef.current;
const nameEl = nameRef.current;
const beforeEl = beforeRef.current;
const afterEl = afterRef.current;
if (!fill || !percentEl || !captionEl || !nameEl || !beforeEl || !afterEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
fill.style.strokeDasharray = String(CIRC);
function applyGauge(ratio: number): void {
const offset = CIRC * (1 - ratio);
fill!.style.strokeDashoffset = String(offset);
}
let raf = 0;
function runMetric(metric: Metric): void {
nameEl!.textContent = metric.label;
captionEl!.textContent = metric.caption;
beforeEl!.textContent = fmt(metric, metric.before);
const targetPercent = computePercent(metric);
const targetRatio = computeRatio(metric);
if (reduce) {
afterEl!.textContent = fmt(metric, metric.after);
percentEl!.textContent = targetPercent + "%";
applyGauge(targetRatio);
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, 3);
const curAfter = metric.before + (metric.after - metric.before) * eased;
const curRatio = targetRatio * eased;
const curPercent = Math.round(targetPercent * eased);
afterEl!.textContent = fmt(metric, curAfter);
percentEl!.textContent = curPercent + "%";
applyGauge(curRatio);
if (t < 1) raf = requestAnimationFrame(frame);
}
applyGauge(0);
percentEl!.textContent = "0%";
afterEl!.textContent = fmt(metric, metric.before);
raf = requestAnimationFrame(frame);
}
let idx = 0;
runMetric(METRICS[0]);
let timer = 0;
if (!reduce) {
timer = window.setInterval(() => {
idx = (idx + 1) % METRICS.length;
runMetric(METRICS[idx]);
}, CYCLE_MS);
}
return () => {
if (timer) window.clearInterval(timer);
if (raf) cancelAnimationFrame(raf);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<section className={styles.panel}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.gaugeWrap}>
<svg className={styles.gaugeSvg} width="132" height="132" viewBox="0 0 132 132" aria-hidden="true">
<circle className={styles.gaugeTrack} cx="66" cy="66" r="54" />
<circle className={styles.gaugeFill} ref={fillRef} cx="66" cy="66" r="54" />
</svg>
<div className={styles.gaugeCenter}>
<span className={styles.gaugePercent} ref={percentRef}>0%</span>
<span className={styles.gaugeCaption} ref={captionRef}>개선</span>
</div>
</div>
<p className={styles.metricName} ref={nameRef}>응답 시간</p>
<p className={styles.metricPair}>
<span className={styles.metricBefore} ref={beforeRef}>420ms</span>
<span className={styles.metricArrow} aria-hidden="true">→</span>
<span className={styles.metricAfter} ref={afterRef}>420ms</span>
</p>
</section>
</main>
);
}03 메트릭 하이라이트 — 이전·이후 비율을 직접 나눠 구하는 강조 카드 — 측정 누적면적 2.328% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 케이스 스터디 사이트 UI 9종 — 성과 수치까지 계산해 보여주는
이 데모가 속한 글
케이스 스터디 사이트 UI 9종 — 성과 수치까지 계산해 보여주는화면에 들어오는 순간 성과 수치가 매 프레임 다시 구해지며 카운트업되고, 사례마다 계산한 임팩트 점수로 그리드 타일 크기가 정해지며, 인용문 길이에 맞춰 폰트 크기가 거꾸로 구해지는 사례 연구 페이지 섹션 9종입니다 이 글의 데모 전부 보기


