03Metric highlight — a card that divides before and after to work out its own ratio
Dividing the before value into the after value works out the ratio that sets the gauge needle's angle or bar length, and the bigger that ratio gets, the deeper the highlight color turns.
Best forResults-metric highlight cards, before/after stat badges
stroke-dashoffsetrequestAnimationFramefont-variant-numeric

<main class="stage">
<section class="panel" id="panel">
<p class="hint" id="hint">The before/after gap is divided to get a ratio, and that ratio fills the gauge by computing the circle's circumference.</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">Improved</span>
</div>
</div>
<p class="metricName" id="metricName">Response time</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: 'Response time', before: 420, after: 180, unit: 'ms', lowerIsBetter: true, caption: 'Improved' },
{ label: 'Conversion rate', before: 2.1, after: 5.8, unit: '%', lowerIsBetter: false, caption: 'Up' },
{ label: 'Bounce rate', before: 61, after: 34, unit: '%', lowerIsBetter: true, caption: 'Improved' }
];
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 Metric highlight — a card that divides before and after to work out its own ratio — Measured area 2.328% · 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 sections04 Discount badge — a badge that works out its own percentage from list price and sale price9 Pricing Page Design Sections (Copy-Paste)
Landing sections06 Before-and-after flip cardTestimonial Section Design: 9 Social Proof Blocks
Landing sections05 Team stat row — a count-up that runs elapsed-time ratio through an easing function9 Team Page Design Patterns (Copy-Paste)