Exclusivobeta
To Scale <mv-to-scale>
Hace comprensibles las cantidades abstractas reformulándolas a una escala humana que el lector ya conoce, junto al valor preciso intacto: 2,4 TB ≈ 800 horas de video HD, 412 t CO₂e ≈ el CO₂ absorbido por 7000 plantones de árbol, 9000 $ ≈ 6 meses de alquiler en Lisboa, 21 600 pedidos/día ≈ un pedido cada 4 segundos, 38 % de los clientes ≈ 2 de cada 5 clientes. Envuelve un número (glosa inline o tarjeta) o recorre un texto entero en busca de spans data-scale. Una biblioteca de comparaciones ampliable cubre datos, energía, CO₂e, masa, tiempo, distancia, personas, dinero, tasas y proporciones, con referencias integradas cuya hipótesis y fuente siempre se muestran; la app añade las suyas por audiencia, configuración regional o persona (el alquiler de la ciudad del lector, las unidades de tu producto). Cada candidata se puntúa según lo fácil que es de captar, así que la elección cae en una cifra redonda entre uno y unas pocas decenas en lugar de “0,003 de…” o “612 345 de…”, y la referencia cambia con la magnitud; dos o tres alternativas quedan a un toque o al pasar el cursor, el cálculo de la conversión y el redondeo están en un desplegable, el sistema métrico o imperial sigue la configuración regional, y las cifras de la misma familia en un texto comparten referencia para poder compararse entre sí. Puntos proporcionales o barra segmentada opcionales con una leyenda en texto, un evento mv-scale-pick cancelable y un MvToScale.express(value, unit, options) estático y puro que devuelve la misma frase, el mismo cálculo y las mismas alternativas en el servidor.
| Categoría | Visualización de datos |
|---|---|
| Tipo | Web Component (<mv-to-scale>) |
| Estado | beta |
| Kit | Cifras que se explican solas |
| 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
Instalación
node scripts/add.mjs to-scale --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["to-scale"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): 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.
Uso
Inicio rápido, el marcado mínimo que funciona:
<p>Your backup holds <mv-to-scale value="2.4 TB">2.4 TB</mv-to-scale>.</p>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<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>Referencia cultural
Los viajes de Gulliver, Jonathan Swift (1726, libro). El mismo hombre es un gigante en Liliput y un juguete en Brobdingnag: su tamaño solo significa algo frente a la gente que lo mide. En la interfaz, una cantidad bruta se vuelve a medir con varas de medir que el lector ya conoce, y el componente elige aquella con la que el número se convierte en una cifra redonda y fácil de captar, con otras varas a un toque.
API
Attributes
| Name | Tipo | 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 | Tipo | 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.