익스클루시브
Real Terms <mv-real-terms>
변화를 0과 비교하는 데 그치지 않고 같은 기간 동안 세상이 어떻게 변했는지와 비교해 보여 주므로, 겨우 따라가기만 하는(또는 뒤처지는) 성장을 진척으로 착각하지 않게 합니다. 매출, 급여, 가격, 트래픽, 시장 점유율, 투자 수익률에 적합합니다.
문화적 레퍼런스
거울 나라의 앨리스, 루이스 캐럴 (1871년, 책). 붉은 여왕의 경주에서 앨리스는 있는 힘껏 달리지만, 풍경 전체가 함께 움직이기 때문에 제자리에 머물러 있음을 깨닫습니다. UI에서는 모든 변화를 같은 기간 동안 세상이 움직인 정도와 비교해 측정하므로, 겨우 따라가는 성장은 보조를 맞춘 것으로, 시장보다 느린 성장은 성장했는데도 뒤처진 것으로 읽힙니다.
작동 방식
변화를 0뿐 아니라 같은 기간 동안 세상이 움직인 정도와 비교해 보여 주므로, 겨우 보조를 맞추는(또는 뒤처지는) 성장을 진전으로 착각하지 않습니다: 매출, 급여, 가격, 트래픽, 시장 점유율, 투자 수익률. 명목 변화(백분율, 또는 시작 금액과 종료 금액)와 앱이 제공하는 하나 이상의 기준선을 전달하며, 각 기준선의 유형은 인플레이션, 벤치마크 또는 지수, 동종 그룹 또는 코호트, 계절(전년 동기), 목표 추세 중 하나입니다. 컴포넌트는 명목 변화, 기준선의 변화, 상대 결과(퍼센트포인트, 또는 복리 비율: 기본값은 인플레이션 차감 후 실질 변화)를 말과 아이콘으로 된 판정과 함께 렌더링합니다: 앞서감, 보조를 맞춤, 뒤처짐. 직관에 반하는 경우에는 명시적으로 이름을 붙이고(“Losing ground despite growing”, “Gaining ground despite falling”, “Losing ground by standing still”) 완전한 문장으로 표현합니다(“Down 3.2%, but the index fell 8.9%: you gained ground (+5.7 pts)”). 금액을 주면 보조를 맞추려면 얼마가 필요했는지도 알려 주고(“Keeping pace with prices meant reaching $88,494: $1,294 short”), 인플레이션 차감 후에는 종료 금액이 기간 시작 시점의 가격으로 얼마의 가치인지 보여 줍니다. Nominal / Real(또는 Relative) 토글로 헤드라인 수치를 전환하고, 컴팩트한 시각화가 두 변화를 하나의 0 기준선 위에 텍스트 대체 표현과 함께 그립니다(차이를 빗금으로 표시한 막대 두 개, 또는 공통 시작점에서 출발하는 기울기 차트). 기준선이 여러 개면 키보드로 탐색할 수 있는 스코어카드로 표시되며, 각 항목이 이미 자신의 판정을 보여 줍니다. 명목 수치는 좋고 나쁨을 나타내는 색으로 칠하지 않으며, 판정에만 색이 들어갑니다. 낮을수록 좋은 지표(비용, 이탈률, 응답 시간)는 판정을 뒤집고, 허용 오차로 “보조를 맞춤”의 범위를 정하며, 숫자는 Intl을 거칩니다. 순수 정적 메서드 MvRealTerms.compare(nominal, baseline, options)는 표, 내보내기, 서버 코드를 위해 같은 계산 결과를 반환합니다. 인라인 변형은 표 셀과 KPI 행에 들어갑니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-real-terms>) |
| 상태 | 안정 |
| 키트 | 스스로 설명하는 숫자 |
| Keywords | exclusive, culture, kpi, benchmark, inflation, real-terms, nominal, relative-performance, market-share, peers, cohort, seasonality, year-over-year, salary, investment, returns, delta, verdict, dashboard, finance |
When to use
- A KPI grew and people need to know whether it beat the market, peers or last year's same period, not just zero
- Salaries, prices, budgets or savings must be read in real terms, after inflation, rather than as nominal amounts
- An investment or fund report must show performance against its benchmark, including when both fell
- A review table should flag metrics that grow but lose ground, or shrink yet gain ground
Avoid when
- A row of headline figures with sparklines is needed, without any external baseline → use Stats instead
- The question is whether a goal will be met by a deadline at the current pace → use Steady Pace instead
- The comparison is a rank among named competitors with the factors behind it → use Mirror Rank instead
설치
node scripts/add.mjs real-terms --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["real-terms"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/real-terms/real-terms.js, components/real-terms/real-terms.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-real-terms label="Base salary" start="86000" end="87200" format="currency">
<script type="application/json" data-baselines>[{ "id": "cpi", "label": "Inflation (CPI)", "kind": "inflation", "change": "2.9%" }]</script>
</mv-real-terms>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="rt-demo" style="width:min(100%,56rem);margin-inline:auto">
<style>
#rt-demo { display:grid; gap:1rem; align-content:start }
#rt-demo .rt-pair { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr)); gap:1rem }
#rt-demo .rt-controls { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1.25rem; flex-wrap:wrap; margin-top:-.25rem; padding:0 .25rem }
#rt-demo .rt-controls span { display:flex; gap:1.25rem; flex-wrap:wrap }
#rt-demo .mv-choice { font-size:.8125rem }
#rt-demo .rt-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
#rt-demo .rt-table { overflow:hidden; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#rt-demo .rt-table h4 { margin:0; padding:.875rem 1.25rem; border-bottom:1px solid var(--mv-border); font-size:.875rem }
#rt-demo table { width:100%; border-collapse:collapse; font-size:.8125rem }
#rt-demo th, #rt-demo td { padding:.625rem 1.25rem; text-align:start; vertical-align:middle }
#rt-demo thead th { color:var(--mv-fg-muted); font-size:.75rem; font-weight:500; background:var(--mv-bg-subtle) }
#rt-demo tbody tr + tr td, #rt-demo tbody tr + tr th { border-top:1px solid var(--mv-border) }
#rt-demo tbody th { font-weight:500; white-space:nowrap }
#rt-demo tbody th small { display:block; color:var(--mv-fg-muted); font-weight:400 }
@media (max-width:40rem) { #rt-demo thead { display:none } #rt-demo tbody tr { display:grid } #rt-demo tbody td { padding-top:0 } }
</style>
<!-- Revenue grew, but the category grew faster: several baselines to compare with -->
<mv-real-terms id="rt-arr" label="Annual recurring revenue" period="FY 2026 · Oct 2025 to Sep 2026"
start="4200000" end="4540000" format="currency" compact baseline="category" subject="Kestrel">
<script type="application/json" data-baselines>
[
{ "id": "category", "label": "Category growth", "name": "the category", "kind": "benchmark", "change": "10.4%",
"note": "Mid-market HR software, analyst consensus.", "source": "Industry revenue survey, Sep 2026" },
{ "id": "peers", "label": "Peer median", "name": "peers", "kind": "peers", "change": "6.2%",
"note": "Median of 14 private SaaS companies with $3M-$8M ARR." },
{ "id": "cpi", "label": "Inflation (CPI)", "kind": "inflation", "change": "2.9%",
"note": "US consumer prices, Sep 2025 to Sep 2026.", "source": "Bureau of Labor Statistics", "sourceUrl": "https://www.bls.gov/cpi/" },
{ "id": "plan", "label": "Board plan", "name": "the plan", "kind": "target", "change": "12%",
"note": "Growth approved in the FY 2026 budget." }
]
</script>
</mv-real-terms>
<div class="rt-controls">
<span>
<label class="mv-choice"><input type="checkbox" class="mv-switch" data-size="sm" id="rt-slope"> Slope chart</label>
<label class="mv-choice"><input type="checkbox" class="mv-switch" data-size="sm" id="rt-lock"> Lock the baseline (veto changes)</label>
</span>
<p class="rt-log" id="rt-log" aria-live="polite">Pick a baseline or switch to Relative.</p>
</div>
<div class="rt-pair">
<!-- A raise that is a pay cut in real terms: shown in the real view -->
<mv-real-terms label="Your base salary" period="Oct 2025 → Oct 2026" start="86000" end="87200"
format="currency" view="real" baseline="cpi">
<script type="application/json" data-baselines>
[
{ "id": "cpi", "label": "Inflation (CPI)", "kind": "inflation", "change": "2.9%", "note": "US consumer prices over the same 12 months." },
{ "id": "role", "label": "Same role, median raise", "name": "the median raise", "kind": "peers", "change": "3.6%", "note": "Senior product designers, US, 2,300 salary reports." }
]
</script>
</mv-real-terms>
<!-- The counterintuitive case: down, yet ahead -->
<mv-real-terms label="Retirement portfolio" period="Q3 2026" start="312400" end="302400"
format="currency" visual="slope" baseline="index" subject="Portfolio">
<script type="application/json" data-baselines>
[
{ "id": "index", "label": "Global equity index", "name": "the index", "kind": "benchmark", "change": "-8.9%", "note": "Total return, dividends reinvested." },
{ "id": "balanced", "label": "60/40 benchmark", "name": "a 60/40 mix", "kind": "benchmark", "change": "-5.1%" }
]
</script>
</mv-real-terms>
</div>
<!-- Inline, in a KPI table: every row judged against its own baseline -->
<section class="rt-table" aria-labelledby="rt-table-title">
<h4 id="rt-table-title">Monthly review · December 2026</h4>
<table>
<thead><tr><th scope="col">Metric</th><th scope="col">Change, in context</th></tr></thead>
<tbody>
<tr>
<th scope="row">Organic sessions<small>Dec vs Nov</small></th>
<td><mv-real-terms variant="inline" change="40%" id="rt-traffic"></mv-real-terms></td>
</tr>
<tr>
<th scope="row">Paid subscribers<small>Year over year</small></th>
<td><mv-real-terms variant="inline" change="0%" id="rt-subs"></mv-real-terms></td>
</tr>
<tr>
<th scope="row">Support cost per ticket<small>Year over year · lower is better</small></th>
<td><mv-real-terms variant="inline" change="2.6%" direction="lower" id="rt-support"></mv-real-terms></td>
</tr>
<tr>
<th scope="row">Hosting cost per user<small>Year over year · lower is better</small></th>
<td><mv-real-terms variant="inline" change="1%" direction="lower" id="rt-hosting"></mv-real-terms></td>
</tr>
</tbody>
</table>
</section>
<script type="module">
const arr = document.getElementById("rt-arr");
const log = document.getElementById("rt-log");
const lock = document.getElementById("rt-lock");
const pct = (x) => `${x > 0 ? "+" : ""}${(x * 100).toFixed(1)}`;
arr.addEventListener("mv-baseline-change", (e) => {
if (lock.checked) {
e.preventDefault();
log.textContent = `Vetoed: the baseline is locked to ${e.detail.from}.`;
return;
}
const r = e.detail.result;
log.textContent = `mv-baseline-change → ${e.detail.to} · ${r.verdict} (${pct(r.relative)}${r.method === "points" ? " pts" : "%"})`;
});
arr.addEventListener("mv-view-change", (e) => { log.textContent = `mv-view-change → ${e.detail.to}`; });
document.getElementById("rt-slope").addEventListener("change", (e) => { arr.visual = e.target.checked ? "slope" : "bars"; });
// Baselines can also be set as a property.
const cpi = { id: "cpi", label: "inflation", kind: "inflation", change: 0.029 };
document.getElementById("rt-traffic").baselines = [{ id: "ly", label: "same period last year", kind: "seasonal", change: 0.45 }];
document.getElementById("rt-subs").baselines = [{ id: "peers", label: "peer median", kind: "peers", change: 0.041 }];
document.getElementById("rt-support").baselines = [cpi];
document.getElementById("rt-hosting").baselines = [cpi];
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
label | string | Metric name shown at the top of the card (as a heading, see heading-level). | |
period | string | The period both changes cover, shown next to the label (“FY 2026”, “Oct 2025 → Oct 2026”). Baselines must describe the same period. | |
start / end | number | Amounts at the start and end of the period. The nominal change is derived from them, and they enable the pace line (what keeping pace meant) and, for inflation, the value at start-of-period prices. | |
change | string | number | Nominal change when you have no amounts: a fraction (0.08) or a percentage string ("8%", "-3.2%"). Takes precedence over start/end for the change itself. | |
baseline | string | Id of the selected baseline (defaults to the first). Reflects the user's choice; setting it is silent (no event). | |
view | nominal | real | nominal | Headline figure: the nominal change, or the result against the selected baseline (real change after inflation, relative change otherwise). The toggle reads “Real” for an inflation baseline and “Relative” for any other. |
direction | higher | lower | higher | Whether a higher value is better. With lower (costs, churn, latency), rising less than the baseline is gaining ground. |
tolerance | string | number | 0.5% | Band within which the result counts as keeping pace: a fraction (0.005) or "0.5%" / "0.5 pts". A result that rounds to zero at the shown precision is always keeping pace. |
method | auto | points | ratio | auto | How the relative result is computed, unless a baseline sets its own: points = nominal − baseline in percentage points; ratio = (1 + nominal) / (1 + baseline) − 1, the compounded real change. auto uses ratio for inflation and points otherwise. |
format | number | currency | percent | number | Intl format of the amounts (start, end, pace). Changes are always percentages. |
currency | string | USD | ISO currency for format="currency". |
compact | boolean | Compact notation for the amounts ($4.54M). | |
unit | string | Suffix for plain number amounts (“sessions”, “seats”). | |
locale | string | en-US | Locale for every number. |
decimals | number | 1 | Fraction digits of percentages and points (0-4). |
variant | card | inline | card | card: full component with toggle, visual and baseline scorecard. inline: one line for tables and KPI rows (nominal change · baseline change · verdict with the difference), the full sentence as a tooltip. |
visual | bars | slope | none | bars | bars: both changes on one zero line, the difference hatched in the verdict color and a dashed mark at the baseline. slope: both lines from a common start (index 100), the gap bracketed at the end. none: text only. |
subject | string | You | Name of the measured thing in the visual (“Kestrel”, “Portfolio”). |
heading-level | number | 3 | Heading level of the label (1-6); 0 renders it as a paragraph. |
data-verdict / data-despite / data-view | ahead | even | behind | none | Set by the component on the host for styling: the verdict for the selected baseline, whether it is a counterintuitive case, and the current view. |
Properties
| Name | 유형 | Description |
|---|---|---|
baselines | Array<{ id, label, name?, kind?: "inflation" | "benchmark" | "peers" | "seasonal" | "target" | "custom", change | start+end, method?, note?, source?, sourceUrl? }> | Baselines for the same period (or a child <script type="application/json" data-baselines>). label is shown in the scorecard and visual; name is used inside sentences (“the category grew 10.4%”; defaults: prices, peers, the same period last year, the target trend, else label). note and source (optional http(s) link) show under the card for the selected one. |
nominal | number | Nominal change as a fraction (read-only). |
selected | object | null | The selected baseline (copy, read-only). |
result | object | null | compare() result for the selected baseline (read-only). |
results | Array<object> | compare() results for every baseline, each with id and label (read-only). |
formatter | (value: number) => string | Custom formatter for the amounts; overrides format, currency, unit and compact. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement: verdict titles (ahead, aheadDespiteDown, aheadDespiteUp, aheadDespiteFlat, behind, behindDespiteUp, behindDespiteDown, behindDespiteFlat, even), sentence parts (subjectUp, subjectDown, subjectFlat, baseUp, baseDown, baseFlat, inflationUp, inflationDown, inflationFlat, targetAny, targetFlat, sentenceDespite, sentenceWhile, sentenceEven, outcomeAhead, outcomeBehind, outcomeEven), units (points, relReal, relRatio), pace lines (paceAbove, paceBelow, paceEven, realAmount), labels (subject, compareWith, showAs, viewNominal, viewReal, viewRelative, captionNominal, captionReal, captionRelative, amounts, gap, source, slopeStart, inlineBase, visualLabel, noBaseline, noData, announce). English defaults with {placeholders}. |
Methods
| Name | Description |
|---|---|
MvRealTerms.compare(nominal, baseline, options?) | Pure static function, no DOM. nominal / baseline: fraction, "8%", { change } or { start, end } (baseline may also carry kind and method). options: { kind, method, direction, tolerance, decimals, start }. Returns null for unreadable input, else { nominal, baseline, kind, method, direction, relative, points, ratio, verdict: "ahead" | "even" | "behind", despite, nominalDirection, baselineDirection, relativeDirection ("up" | "down" | "flat") } plus, with start, { start, end, pace, gapValue, realEnd }. |
select(id) | Selects a baseline as the user would: emits the cancelable mv-baseline-change. Returns false if vetoed or unknown. |
setView(view) / toggleView() | Switches between "nominal" and "real" as the user would: emits the cancelable mv-view-change. Returns false if vetoed. |
Events
| Name | Description |
|---|---|
mv-baseline-change | Cancelable, before the user (or select()) changes the baseline. detail: { from, to, baseline, result } with the compare() result for the new baseline. preventDefault() keeps the current one (focus returns to it). |
mv-view-change | Cancelable, before switching the headline between nominal and real. detail: { from, to }. |
Content structure
| Name | Description |
|---|---|
script[data-baselines] | A child <script type="application/json" data-baselines> with the baselines array, read once on first connection (the baselines property wins if set before). |
CSS classes
| Name | Description |
|---|---|
mv-real-terms-root | The card (a container-query box; the figure and visual sit side by side from 36rem): -head, -titles, -label, -period, -views / -view (toggle), -body, -main, -foot, -empty. |
mv-real-terms-figure | Headline: -number (-arrow + -value), -sub (-caption, -amounts). |
mv-real-terms-story | -verdict (pill with -verdict-icon and -verdict-text), -sentence, -pace. |
mv-real-terms-visual | role="img" with a text label. Bars: -bars, -rails (-zero, -mark), -row (-row-you, -row-base, -row-gap) with -bar-name, -track, -bar, -bar-value. Slope: -slope, -plot, -slope-svg (-slope-you, -slope-base, -slope-gap, -slope-zero), -dot, -slope-start, -ends (-end, -end-you, -end-gap, -end-base with -end-name and -end-value). |
mv-real-terms-picker | Baseline scorecard: -options (radiogroup) of -option buttons (-option-label, -option-change, -option-rel), each with data-verdict. |
mv-real-terms-inline | Inline variant: -inline-nominal, -inline-base, -sep, -verdict. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-real-terms-you | var(--mv-accent) | Color of the measured thing (bar, slope line). |
--mv-real-terms-baseline | muted gray | Color of the baseline bar and dashed slope line. |
--mv-real-terms-ahead | var(--mv-success) | Verdict color when gaining ground. |
--mv-real-terms-behind | var(--mv-danger) | Verdict color when losing ground. |
--mv-real-terms-even | var(--mv-info) | Verdict color when keeping pace. |
--mv-real-terms-padding | var(--mv-space-5) | Card inner padding. |
--mv-real-terms-bar-height | 0.75rem | Bar thickness. |
--mv-real-terms-slope-height | 8.5rem | Height of the slope chart. |
Accessibility
The verdict is always stated in words and never by color alone: a pill with a distinct icon per verdict (rising trend, falling trend, equals) and a text title, followed by a full sentence that names both changes and the difference with its sign and unit (“Up 8.1%, but the category grew 10.4%: you lost ground (−2.3 pts)”). The nominal arrow shows the direction of the number only and stays neutral, so a green “up” can never be read as “good” when the business lost ground. The visual is a role="img" element whose label gives the same numbers as text (“Kestrel +8.1%; Category growth +10.4%; difference −2.3 pts.”), and its value labels are real text; the baseline in the slope chart is dashed, so the two lines differ by pattern too. The Nominal / Real toggle is a labelled group of two buttons with aria-pressed. Baselines form a radiogroup (“Compare with”) with a roving tabindex: Tab enters on the selected option, arrow keys, Home and End move and select, and each option's accessible name carries its baseline change and verdict (“Peer median: peers grew 6.2%. Gaining ground (+1.9 pts)”). A vetoed change keeps focus on the still-selected option. Changing the baseline or the view announces the new verdict and sentence once in a polite live region. Numbers use Intl for the locale and a true minus sign, which screen readers read as “minus”. The inline variant keeps its visible text self-explanatory and adds the full sentence as a title. Forced-colors mode draws the bars and lines with system colors and marks the pressed toggle and selected option with an outline. Reduced motion (OS or data-motion="reduce"): bars, lines and marks appear in place without growing or sliding.