Exclusivebeta
To Scale <mv-to-scale>
Makes abstract quantities graspable by restating them at a human scale the reader already knows, next to the untouched precise value: 2.4 TB ≈ 800 hours of HD video, 412 t CO₂e ≈ the CO₂ absorbed by 7,000 tree seedlings, $9,000 ≈ 6 months of rent in Lisbon, 21,600 orders/day ≈ one order every 4 seconds, 38% of customers ≈ 2 in 5 customers. Wrap one number (inline gloss or card) or scan a whole text for data-scale spans. A pluggable comparison library covers data, energy, CO₂e, mass, time, distance, people, money, rates and shares, with built-in yardsticks whose assumption and source are always shown; the app adds its own per audience, locale or persona (the reader's city rent, your product's units). Every candidate is scored for graspability, so the pick lands on a round figure between one and a few dozen instead of “0.003 of…” or “612,345 of…”, and the yardstick changes with the magnitude; two or three alternatives sit one tap or hover away, the conversion math and rounding are in a disclosure, metric or imperial follows the locale, and figures of the same family in one text share a yardstick so they compare with each other. Optional proportional dots or segmented bar with a text key, a cancelable mv-scale-pick event, and a pure static MvToScale.express(value, unit, options) that returns the same sentence, math and alternatives on the server.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-to-scale>) |
| Status | beta |
| Kit | Numbers that explain themselves |
| Keywords | exclusive, culture, numbers, units, comparison, human-scale, data-literacy, numeracy, explainer, co2, carbon, energy, storage, bytes, money, cost-of-living, rate, percentage, natural-frequency, metric, imperial, ssr, reports, journalism |
When to use
- A dashboard or plan page shows storage, bandwidth or quota figures that users cannot picture (GB, TB, requests)
- A sustainability, energy or impact report must make tonnes of CO₂e or gigawatt-hours tangible without losing the exact figure
- A price, budget or savings goal should be restated in terms of the reader's own cost of living, supplied per city or persona
- An article or AI answer is full of large numbers, rates and percentages that should each get an honest everyday equivalent
Avoid when
- The number is a headline KPI that should count up or animate on arrival rather than be restated → use Number Ticker instead
- Readers need to audit how a figure was computed from its inputs, not what it is comparable to → use Curtain instead
- Several options are weighed against each other point by point (pros and cons, build vs buy) → use Balance Sheet instead
Install
node scripts/add.mjs to-scale --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["to-scale"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/position.js, components/to-scale/to-scale.js, components/to-scale/to-scale.css.
Usage
Quick start, the smallest working markup:
<p>Your backup holds <mv-to-scale value="2.4 TB">2.4 TB</mv-to-scale>.</p>Canonical markup, to start from and customize with attributes, data-* and CSS variables:
<div id="ts-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#ts-demo { display:grid; gap:1.25rem; align-content:start }
#ts-demo .ts-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1.25rem; align-items:start }
#ts-demo .ts-col { display:grid; gap:.75rem; min-width:0 }
#ts-demo .ts-bar { display:flex; align-items:center; justify-content:space-between; gap:.5rem .75rem; flex-wrap:wrap }
#ts-demo .ts-bar h3 { margin:0; font-size:.9375rem; font-weight:650; letter-spacing:-.01em }
#ts-demo .ts-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#ts-demo .ts-note { margin:0; padding:0 .25rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.6 }
#ts-demo .ts-report { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs); overflow:hidden }
#ts-demo .ts-report-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
#ts-demo .ts-report-head h3 { display:flex; align-items:center; gap:.5rem; margin:0; font-size:1rem; font-weight:650; letter-spacing:-.01em }
#ts-demo .ts-report-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#ts-demo .ts-report mv-to-scale { padding:1.125rem 1.25rem .25rem; max-width:46rem }
#ts-demo #ts-report > p { margin:0 0 1rem; font-size:.9375rem; line-height:1.85; text-wrap:pretty }
#ts-demo .ts-report [data-scale] { font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap }
#ts-demo .ts-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#ts-demo .ts-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
#ts-demo .mv-choice { font-size:.8125rem }
@media (max-width:46rem) { #ts-demo .ts-grid { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="ts-grid">
<!-- Storage: the yardstick changes with the magnitude -->
<section class="ts-col" aria-labelledby="ts-storage-t">
<div class="ts-bar">
<div><h3 id="ts-storage-t">Cloud backup</h3><p>Same number, human scale.</p></div>
<mv-segmented id="ts-plan" aria-label="Backup size" value="2.4 TB">
<button value="250 GB">250 GB</button>
<button value="2.4 TB">2.4 TB</button>
<button value="18 TB">18 TB</button>
</mv-segmented>
</div>
<mv-to-scale id="ts-storage" variant="card" label="Photo library backup" value="2.4 TB" visual="dots"></mv-to-scale>
<p class="ts-note">This month you added <mv-to-scale value="86" unit="GB">86 GB</mv-to-scale> across 3 devices.</p>
</section>
<!-- Money: the app supplies the reader's own reference set -->
<section class="ts-col" aria-labelledby="ts-fund-t">
<div class="ts-bar">
<div><h3 id="ts-fund-t">Savings goal</h3><p>Priced in the reader’s city.</p></div>
<mv-segmented id="ts-city" aria-label="Reader’s city" value="lisbon">
<button value="lisbon">Lisbon</button>
<button value="austin">Austin</button>
<button value="bengaluru">Bengaluru</button>
</mv-segmented>
</div>
<mv-to-scale id="ts-fund" variant="card" label="Emergency fund target" value="9000" unit="USD" visual="bar" audience="lisbon" defaults="false"></mv-to-scale>
</section>
</div>
<!-- Report: every [data-scale] figure in the text gets a gloss; one yardstick per unit family -->
<section class="ts-report" aria-labelledby="ts-report-t">
<header class="ts-report-head">
<div>
<h3 id="ts-report-t">Q3 2026 impact report <span class="mv-badge" data-variant="secondary" data-size="sm">Audited</span></h3>
<p>Northwind Cloud · published Oct 5, 2026</p>
</div>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="ts-metric"> Metric units</label>
</header>
<mv-to-scale id="ts-report" scan visual="dots">
<p>Our three data centers used <span data-scale>1.8 GWh</span> of electricity this quarter and emitted <span data-scale>412 t CO2e</span>, down from <span data-scale>530 t CO2e</span> in Q3 2025.</p>
<p>At peak, the storefront handled <span data-scale>21,600 orders/day</span>, and <span data-scale>38%</span> of customers picked carbon-neutral shipping. Delivery vans covered <span data-scale>3.2 million km</span>, and employees volunteered <span data-scale>2,400 hours</span> with local schools.</p>
</mv-to-scale>
<div class="ts-foot">
<p class="ts-log" id="ts-log" aria-live="polite">Hover or tap a gloss to see alternatives and the math.</p>
</div>
</section>
<script type="module">
const storage = document.getElementById("ts-storage");
const fund = document.getElementById("ts-fund");
const report = document.getElementById("ts-report");
const log = document.getElementById("ts-log");
document.getElementById("ts-plan").addEventListener("mv-change", (e) => { storage.value = e.detail.value; });
// The reader's reference set, supplied by the app (demo figures, in USD).
const src = "Demo reference set, 2026. Replace with your own data.";
const city = (id, name, rent, groceries, transit) => [
{ id: `${id}-rent`, audience: id, unit: "USD", per: rent, one: `month of rent in ${name}`, other: `months of rent in ${name}`, assumption: `a one-bedroom apartment in central ${name} at about $${rent.toLocaleString("en-US")} a month`, source: src, weight: 1.4 },
{ id: `${id}-groceries`, audience: id, unit: "USD", per: groceries, one: `month of groceries in ${name}`, other: `months of groceries in ${name}`, assumption: `groceries for one person in ${name} at about $${groceries} a month`, source: src },
{ id: `${id}-transit`, audience: id, unit: "USD", per: transit, one: `monthly transit pass in ${name}`, other: `monthly transit passes in ${name}`, assumption: `an unlimited monthly public transit pass in ${name} at about $${transit}`, source: src, weight: 0.8 },
];
fund.comparisons = [
...city("lisbon", "Lisbon", 1500, 380, 45),
...city("austin", "Austin", 1750, 450, 41),
...city("bengaluru", "Bengaluru", 420, 170, 25),
];
document.getElementById("ts-city").addEventListener("mv-change", (e) => { fund.audience = e.detail.value; });
document.getElementById("ts-metric").addEventListener("change", (e) => {
report.system = e.target.checked ? "metric" : "imperial";
});
document.getElementById("ts-demo").addEventListener("mv-scale-pick", (e) => {
log.textContent = `mv-scale-pick · ${e.detail.original} → ${e.detail.text}`;
});
</script>
</div>Cultural reference
Gulliver's Travels, Jonathan Swift (1726, book). The same man is a giant in Lilliput and a toy in Brobdingnag: his size only means something against the people who measure him. In the UI, a raw quantity is measured again against yardsticks the reader already knows, and the component picks the yardstick at which the number becomes a round, graspable figure, with other yardsticks one tap away.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
value | number | string | The quantity. With unit, a machine number (“.” decimal, optional “,” grouping). Without unit, a full quantity string: "2.4 TB", "$9,000", "$4.2M", "1.2 million users", "21,600 orders/day", "38%", "412 t CO2e". Ignored with scan. | |
unit | string | Unit of value: data (B, KB, MB, GB, TB, PB, KiB…GiB), energy (Wh…TWh, J, kJ, MJ, GJ, kcal, BTU, therm), CO₂e (g/kg/t/Mt CO2e, lb CO2e), mass (mg, g, kg, t, lb, oz), time (ms, s, min, h, days, weeks, months, years), distance (mm, cm, m, km, in, ft, yd, mi), people (people, users, customers, visitors, members…), share (%), money (ISO code or $, €, £, ¥, ₹), rates (“orders/day”, “per hour”, “sign-ups per week”). | |
variant | inline | card | inline | inline: the precise value followed by a “≈ 800 hours of HD video” gloss button that opens a popover (alternatives, picture, math). card: a block with label, large precise value, the comparison sentence, alternatives as chips and the math disclosure. |
scan | boolean | Container mode: every descendant [data-scale] element (its attribute value, else its text, is the quantity) gets a gloss right after it and shares one popover. The app's text is never rewritten. Added, removed or edited figures are picked up automatically. | |
visual | none | dots | bar | none | Tiny proportional picture of the pick, always with a text key: dots (whole things, or round groups of them when many: “Each dot is 100 hours of HD video”), bar (the same as a segmented bar). Shares draw an icon array (2 of 5 filled); rates shown as intervals of 0.3-30 s draw a dot that pulses at that tempo (static under reduced motion). |
locale | BCP 47 tag | closest [lang], else en-US | Number formatting (grouping, “1.5 million”) and the default measurement system. |
system | auto | metric | imperial | auto | Which built-in yardsticks apply (miles driven vs kilometers driven, gallons vs liters, US football fields vs soccer pitches). auto: imperial for US, Liberia and Myanmar locales, metric elsewhere. |
audience | string (space or comma separated) | Activates the app comparisons tagged with a matching audience (a city, persona, country, customer segment). Audience comparisons get a strong bonus so the reader's own reference points win when they fit. | |
prefer | comparison ids | Bias the pick toward these ids (in order) whenever they give a sensible figure. data-scale-prefer does the same on one scanned figure. | |
exclude | comparison ids | Never use these ids. | |
alternatives | number | 3 | How many other yardsticks are offered next to the pick (only those scoring close enough to the best). |
consistent | "true" | "false" | true | In scan mode, figures of the same family (and currency) use one shared yardstick when it fits all of them, so “412 t” and “530 t” read as 7,000 and 9,000 tree seedlings; picking an alternative on one applies it to the others where it fits. "false": each figure gets its own best pick. |
defaults | "true" | "false" | true | "false" drops the built-in library: only your comparisons (property, MvToScale.register) are used, e.g. for money in a non-US audience. |
subject | string ("order|orders" or a plural) | What is being counted, for rates, shares and people: “one order every 4 seconds”, “2 in 5 customers”. Inferred from the unit (“orders/day”, “users”) when omitted. data-scale-subject on a scanned figure. | |
label | string | Card variant: caption above the precise value (“Photo library backup”). | |
data-scale / data-scale-unit / data-scale-subject / data-scale-prefer | on scanned elements | Marks a figure in scan mode. data-scale holds the quantity (or leave it empty to read the element's text); data-scale-unit gives the unit separately; the others override subject and prefer for that figure. The component sets data-scale-state="ready | none | invalid" on it. | |
data-state / data-variant / data-family | set by the component | On the host: ready | none (no comparison fits) | invalid (unknown unit); inline | card | scan; the unit family of the first figure. |
Properties
| Name | Type | Description |
|---|---|---|
comparisons | Comparison[] | App comparisons merged with the built-ins (same id replaces a built-in). Comparison: { id, unit, per (how many units one thing is), one, other (noun labels), phrase? (sentence with {about} and {np}, e.g. "the length of {about}{np}"), assumption? (noun phrase shown as “Assumes …”), source?, href? (http(s) only), weight? (familiarity, default 1), audience?, system?, locales?, min? / max? (sensible count range), fractions? (false forbids “half a …”), exact? (a definition, not an estimate), article? }. |
result | Result | null | Read-only: the first figure's result, the same object express() returns: { ok, value, unit, family, currency, original, locale, system, pick, alternatives, options }. pick and each alternative: { id, origin, n (exact count), rounded, count, approx, deviation, np, text, gloss, per, math[], assumption, source, href, visual, score }. |
results | Array<{ element } & Result> | Read-only: one result per figure (scan mode), with the scanned element. |
strings | Partial<Record<string, string>> | Overrides for visible texts, announcements and engine words (keys: exact, pictureAs, math, assumes, source, none, invalid, eachDot, eachSegment, shareKey, tempo, glossHint, lensLabel, announce, announceGroup, announceGroupOne, aboutSr, about, one, rounded, even, arithmetic, every, everyNoun, perUnit, perUnitNoun, inN, inNSubject, events). English defaults. |
value / unit / variant / scan / visual / locale / system / audience / prefer / exclude / alternatives / consistent / defaults / subject / label | reflected | Mirror the attributes; setting value, unit, audience or system recomputes at once. |
Methods
| Name | Description |
|---|---|
MvToScale.express(value, unit, options) | Static and pure (no DOM, SSR-safe; also a named export express). express(2.4, "TB") or express("2.4 TB", null, { locale, system, audience, comparisons, defaults, prefer, exclude, pick, alternatives, subject, strings }). Returns the Result object with the sentence, math, assumption, source and alternatives, ready to render in an email, a PDF or server HTML. |
MvToScale.parse(text) | Static: "$4.2M" → { value: 4200000, unit: "USD" }; "1.2 million users" → { value: 1200000, unit: "users" }; null when no number is found (also a named export parseQuantity). |
MvToScale.register(comparisons) | Static: adds comparisons for every instance on the page (same id replaces), then refreshes the live ones. |
MvToScale.library | Static getter: copies of the built-in comparisons with their family, assumption and source, to document or audit them. |
pick(id, target?) | Shows comparison id for a figure (scanned element, selector, index; default the first). Emits the cancelable mv-scale-pick with trigger "api". Returns false if vetoed or if the comparison does not fit this value. |
open(target?) / close() | Opens or closes the popover of an inline or scanned figure. |
refresh() | Rescans data-scale figures and recomputes (after changing comparisons in place). |
Events
| Name | Description |
|---|---|
mv-scale-pick | Cancelable, before another yardstick is shown. detail: { element, id, previous, text, n, value, unit, original, family, choice, trigger: "user" | "api" }. preventDefault() keeps the current one; use it to persist a reader's preference. |
mv-scale-toggle | The popover opened or closed. detail: { open, element, trigger ("hover" | "click" | "api") when opening }. |
Content structure
| Name | Description |
|---|---|
(content) | Standalone: optional text shown as the precise value (“2.4 TB”, also the no-JS fallback); when empty the value is formatted for you. Scan mode: any rich text containing [data-scale] figures. |
CSS classes
| Name | Description |
|---|---|
mv-to-scale-value | The precise value (standalone), untouched app text when given. |
mv-to-scale-gloss | Inline role="button" span after the figure: .mv-to-scale-sign (≈ or =) and .mv-to-scale-gloss-text; aria-expanded while its popover is open. |
mv-to-scale-lens | The popover (Popover API, role=dialog), placed right after its gloss in the DOM: .mv-to-scale-exact, then the panel. |
mv-to-scale-card | Card variant: .mv-to-scale-head (.mv-to-scale-label, .mv-to-scale-value) and .mv-to-scale-body. |
mv-to-scale-say / mv-to-scale-count | The comparison sentence and its highlighted figure. |
mv-to-scale-visual | Picture: .mv-to-scale-art (aria-hidden) with .mv-to-scale-dot / .mv-to-scale-seg (--_f fill 0..1) or .mv-to-scale-pulse, and the .mv-to-scale-key caption; data-kind count | share | tempo, data-style dots | bar. |
mv-to-scale-alts / mv-to-scale-alt | “Picture it as” group of toggle buttons (aria-pressed): .mv-to-scale-alt-check, -alt-text, -alt-per (the assumption in short: “3 GB each”). |
mv-to-scale-math | <details> “How this is calculated”: .mv-to-scale-steps (ordered steps, with the rounding), .mv-to-scale-assume, .mv-to-scale-source. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-to-scale-accent | var(--mv-accent) | Tint of the gloss, dots, bar and selected alternative. |
--mv-to-scale-ink | accent mixed with --mv-fg | Text-safe version of the tint (≈ sign, check marks). |
Accessibility
The comparison is always text, never only a picture or a color: the gloss is an inline role="button" (tabindex 0, Enter and Space, a span rather than a <button> so it wraps with the sentence on narrow screens) whose name reads “about 800 hours of HD video” (the ≈ sign is aria-hidden and replaced by “about” for screen readers), described by a hint saying it opens other comparisons and the math, with aria-haspopup="dialog", aria-expanded and aria-controls. The precise value stays in the text next to it and is repeated as “Exact value” in the popover. The popover is a labelled non-modal dialog placed right after its gloss in the DOM, so Tab walks into it naturally; opening it from the keyboard moves focus to the selected alternative, Escape closes it and returns focus to the gloss, and focus leaving it or a click outside closes it. Hover only previews (after a short intent delay, mouse only) and never steals focus; touch and click open it pinned. Alternatives are toggle buttons (aria-pressed) in a labelled group, reachable with Tab or the arrow keys, Home and End; choosing one is announced politely (“Now showing about 600,000 photos. Also applied to 1 other figure.”). The math is a native <details> disclosure with ordered steps, the rounding made explicit, the assumption and the source. Dots and bars are aria-hidden and always come with a visible text key (“Each dot is 100 hours of HD video.”), so nothing depends on color or shape. Reduced motion (OS or data-motion="reduce"): the tempo dot stops pulsing and the popover appears without sliding. Forced colors: glosses use button colors, dots and bars are drawn in CanvasText, the selected alternative gets a Highlight border.