Exclusivobeta
Mirror Rank <mv-mirror-rank>
Um ranking que se explica com honestidade e diz o que seria preciso para subir: classificações (vendas, colaboradores), rankings de vendedores ou de busca, pontuações de crédito, de saúde, de SEO ou de segurança. Forneça fatores (peso, faixa, passo, sentido, unidade, custo opcional) e entradas com valores por fator, ou a sua própria função score(values) com um detalhamento por fator; ele exibe o ranking como uma lista ordenada com uma barra de contribuição empilhada por entrada e, para a entrada selecionada (a de quem está vendo, por padrão), um painel espelho: a pontuação detalhada por fator em uma tabela real (valor, pontos sobre o máximo, uma marca onde está a meta, diferença com sinal), a distância até a próxima posição (“2.4 points behind Amara Okafor (#4)”) e as menores mudanças concretas que a eliminariam (“Close 3 more deals” ou “Bring in $60k more revenue” ou “Respond 3 h faster”), calculadas testando variações por fator dentro de cada faixa declarada e ordenadas por esforço, com combinações de dois fatores ou gulosas quando nenhuma mudança sozinha basta e um veredito claro de “out of reach” quando nada dentro da faixa é suficiente. Passar o mouse ou focar uma sugestão mostra uma prévia do efeito dela em cada barra e no medidor de distância; clicar a fixa. O líder vê, em vez disso, o menor deslize que lhe custaria o primeiro lugar. Um texto curto responde “why is #1 first?” (principais vantagens sobre o segundo colocado, onde o segundo está à frente, a margem), e o modo honesto hachura e rotula os fatores que ninguém pode mudar (tempo de casa, idade da conta) e diz quanto de uma distância ou de uma vantagem vem deles. Mire na próxima posição ou no primeiro lugar, limite listas longas aos N primeiros mais a vizinhança da entrada selecionada, e acompanhe as seleções por um mv-select-entry cancelável e cada plano calculado por mv-what-it-takes.
| Categoria | Exibição de dados |
|---|---|
| Tipo | Web Component (<mv-mirror-rank>) |
| Status | beta |
| Kit | Números que se explicam sozinhos |
| Keywords | exclusive, culture, leaderboard, ranking, score, score-breakdown, explainable, what-if, gap, next-rank, gamification, seller-rank, search-ranking, credit-score, health-score, transparency, table, list |
When to use
- A leaderboard or seller ranking should tell each person why they are where they are and the smallest step that moves them up
- A composite score (credit, account health, SEO, security posture) must show its breakdown and the cheapest ways to raise it
- A marketplace or search ranking needs a transparent “why is my listing here?” view for sellers, including factors they cannot change
- Gamified progress should motivate with concrete, reachable targets instead of an opaque number
Avoid when
- People just need to sort and filter many rows by any column, with no scoring model behind the order → use Data table instead
- Progress is measured against your own previous attempt rather than against other people → use Ghost Run instead
- The ranking model is secret or adversarial (fraud, spam scores): explaining it would teach people to game it
Instalação
node scripts/add.mjs mirror-rank --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["mirror-rank"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, components/mirror-rank/mirror-rank.js, components/mirror-rank/mirror-rank.css.
Uso
Início rápido, a menor marcação que funciona:
<mv-mirror-rank you="aiko" label="Q3 sales leaderboard">
<script type="application/json">{
"factors": [{ "key": "deals", "label": "Deals won", "min": 0, "max": 30 }, { "key": "csat", "label": "CSAT", "min": 60, "max": 100, "unit": "%" }],
"entries": [{ "id": "priya", "name": "Priya Raman", "values": { "deals": 22, "csat": 94 } }, { "id": "aiko", "name": "Aiko Tanaka", "values": { "deals": 20, "csat": 92 } }, { "id": "mei", "name": "Mei Chen", "values": { "deals": 14, "csat": 87 } }]
}</script>
</mv-mirror-rank>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<div id="mr-demo" style="width:min(100%,76rem);margin-inline:auto">
<style>
#mr-demo .mr-top { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; margin:0 0 1rem }
#mr-demo .mr-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#mr-demo .mr-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#mr-demo .mr-controls { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap }
#mr-demo .mr-controls .mv-choice { gap:.625rem; font-size:.8125rem }
#mr-demo .mr-foot { display:flex; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
#mr-demo .mr-foot code { font-family:var(--mv-font-mono); font-size:.6875rem; color:var(--mv-fg) }
</style>
<div class="mr-top">
<div>
<h3 id="mr-title">Q3 sales leaderboard</h3>
<p id="mr-sub">Account executives · updated Sep 24, 2026</p>
</div>
<div class="mr-controls">
<mv-segmented id="mr-scenario" value="sales" aria-label="Scenario">
<button value="sales">Sales team</button>
<button value="search">Search ranking</button>
</mv-segmented>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="mr-honest" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Honest mode</span></span>
</label>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="mr-week">Simulate next week</button>
</div>
</div>
<mv-mirror-rank id="mr-rank" you="aiko" honest label="Q3 sales leaderboard">
<script type="application/json">
{
"factors": [
{ "key": "deals", "label": "Deals won", "weight": 30, "min": 0, "max": 30, "step": 1, "unit": ["deal", "deals"], "improve": "Close {n} more {unit}", "slip": "{n} fewer {unit} won" },
{ "key": "revenue", "label": "Revenue closed", "weight": 25, "min": 0, "max": 600, "step": 10, "prefix": "$", "unit": "k", "improve": "Bring in {value} more revenue", "slip": "Revenue {value} lower" },
{ "key": "response", "label": "Lead response", "weight": 20, "better": "lower", "min": 1, "max": 24, "step": 0.5, "unit": "h", "improve": "Respond {value} faster", "slip": "Responses {value} slower" },
{ "key": "csat", "label": "CSAT", "weight": 15, "min": 60, "max": 100, "step": 1, "unit": "%", "improve": "Lift CSAT by {n} points", "slip": "CSAT {n} points lower" },
{ "key": "tenure", "label": "Tenure", "weight": 10, "min": 0, "max": 60, "step": 1, "unit": "mo", "fixed": "Accrues with time on the team" }
],
"entries": [
{ "id": "priya", "name": "Priya Raman", "meta": "EMEA · Enterprise", "values": { "deals": 22, "revenue": 480, "response": 2, "csat": 94, "tenure": 38 } },
{ "id": "marcus", "name": "Marcus Olsen", "meta": "Nordics · Mid-market", "values": { "deals": 24, "revenue": 410, "response": 3.5, "csat": 88, "tenure": 52 } },
{ "id": "amara", "name": "Amara Okafor", "meta": "West Africa · SMB", "values": { "deals": 19, "revenue": 360, "response": 1.5, "csat": 96, "tenure": 20 } },
{ "id": "diego", "name": "Diego Fernández", "meta": "LATAM · Enterprise", "values": { "deals": 17, "revenue": 450, "response": 4, "csat": 90, "tenure": 44 } },
{ "id": "aiko", "name": "Aiko Tanaka", "meta": "APAC · Mid-market", "values": { "deals": 20, "revenue": 400, "response": 4.5, "csat": 92, "tenure": 14 } },
{ "id": "lucas", "name": "Lucas Moreau", "meta": "France · SMB", "values": { "deals": 16, "revenue": 300, "response": 2.5, "csat": 89, "tenure": 30 } },
{ "id": "sofia", "name": "Sofia Rossi", "meta": "Southern Europe · Mid-market", "values": { "deals": 15, "revenue": 340, "response": 5, "csat": 93, "tenure": 26 } },
{ "id": "jonas", "name": "Jonas Weber", "meta": "DACH · Enterprise", "values": { "deals": 13, "revenue": 390, "response": 6.5, "csat": 85, "tenure": 48 } },
{ "id": "mei", "name": "Mei Chen", "meta": "Greater China · SMB", "values": { "deals": 14, "revenue": 260, "response": 2, "csat": 87, "tenure": 9 } },
{ "id": "omar", "name": "Omar Haddad", "meta": "Middle East · Mid-market", "values": { "deals": 11, "revenue": 280, "response": 7.5, "csat": 84, "tenure": 16 } }
]
}
</script>
</mv-mirror-rank>
<div class="mr-foot">
<span>Pick anyone in the list to see their mirror · hover a suggestion to preview it, press it to pin</span>
<span id="mr-event" aria-hidden="true"></span>
</div>
<script type="module">
const rank = document.getElementById("mr-rank");
await customElements.whenDefined("mv-mirror-rank");
const $ = (id) => document.getElementById(id);
const clone = (x) => JSON.parse(JSON.stringify(x));
const sales = { factors: clone(rank.factors), entries: clone(rank.entries) };
// Next week: Aiko closes three deals and answers leads faster; the others move a little.
const nextWeek = sales.entries.map((e) => {
const bump = { aiko: { deals: 3, response: -1.5, revenue: 40 }, amara: { deals: 1 }, diego: { revenue: 20 }, marcus: { deals: 1, csat: 1 }, lucas: { deals: 2 }, mei: { deals: 1 } }[e.id] ?? {};
const values = { ...e.values };
for (const [k, d] of Object.entries(bump)) values[k] = +(values[k] + d).toFixed(1);
return { ...e, values };
});
// Search scenario: the marketplace's own (non-linear) relevance score, explained.
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const search = {
factors: [
{ key: "match", label: "Query match", min: 0, max: 100, step: 1, unit: "%", maxPoints: 30, improve: "Raise query match by {n} points" },
{ key: "rating", label: "Rating", min: 1, max: 5, step: 0.1, decimals: 1, maxPoints: 25, improve: "Lift the rating by {n}", format: (v) => `${v.toFixed(1)} / 5` },
{ key: "reviews", label: "Reviews", min: 0, max: 5000, step: 10, unit: ["review", "reviews"], maxPoints: 15, improve: "Collect {n} more {unit}" },
{ key: "price", label: "Price vs median", better: "lower", min: -30, max: 30, step: 1, maxPoints: 12, improve: "Cut the price by {n}%", format: (v) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v)}%` },
{ key: "shipping", label: "Delivery time", better: "lower", min: 1, max: 7, step: 1, unit: ["day", "days"], maxPoints: 10, improve: "Deliver {value} sooner" },
{ key: "history", label: "Seller history", min: 0, max: 48, step: 1, unit: "mo", maxPoints: 8, fixed: "Account age, grows on its own" },
],
score: (v) => {
const parts = {
match: 30 * clamp(v.match / 100, 0, 1),
rating: 25 * clamp((v.rating - 3.5) / 1.5, 0, 1),
reviews: 15 * Math.min(1, Math.log10(1 + Math.max(0, v.reviews)) / Math.log10(5001)),
price: 12 * clamp((30 - v.price) / 60, 0, 1),
shipping: 10 * clamp((7 - v.shipping) / 6, 0, 1),
history: 8 * Math.min(1, v.history / 48),
};
return { total: Object.values(parts).reduce((a, b) => a + b, 0), parts };
},
entries: [
["northwind", "Northwind Audio", "Pulse ANC Buds · $119.00", 95, 4.7, 3200, -5, 2, 60],
["kestrel", "Kestrel Sound", "Glide Earbuds 2 · $99.00", 90, 4.6, 1800, 0, 1, 40],
["lumen", "Lumen & Co.", "Halo Mini · $129.00", 88, 4.8, 950, 6, 2, 36],
["harbor", "Harbor Tech", "WaveFit Pro · $89.00", 92, 4.4, 2600, -10, 3, 52],
["aurelia", "Aurelia Goods", "Calm Buds · $94.00", 85, 4.5, 1400, -4, 2, 30],
["juniper", "Juniper Direct", "AirLite Pro · $79.99", 80, 4.6, 420, -8, 3, 14],
["pine", "Pine Street Electronics", "Tempo X · $104.00", 86, 4.3, 2100, 4, 2, 48],
["mistral", "Mistral Supply", "Breeze Buds · $74.00", 78, 4.5, 700, -12, 4, 26],
["quokka", "Quokka Gear", "Hop Buds · $97.00", 82, 4.2, 1100, 2, 2, 20],
["sable", "Sable Audio Lab", "Studio Pods · $139.00", 75, 4.7, 300, 10, 3, 18],
["tidewater", "Tidewater Outlet", "Surf Buds · $64.00", 70, 4.1, 1600, -15, 5, 44],
["orbit", "Orbit Electronics", "Nova Lite · $84.00", 72, 4.0, 500, -2, 4, 10],
].map(([id, name, meta, match, rating, reviews, price, shipping, history]) => ({ id, name, meta, values: { match, rating, reviews, price, shipping, history } })),
};
let week = 0;
const show = (scenario) => {
const isSales = scenario === "sales";
rank.score = isSales ? null : search.score;
rank.factors = isSales ? sales.factors : search.factors;
rank.entries = isSales ? (week ? nextWeek : sales.entries) : search.entries;
rank.you = isSales ? "aiko" : "juniper";
rank.selected = null;
rank.target = "next";
rank.limit = isSales ? 0 : 4;
rank.label = isSales ? "Q3 sales leaderboard" : "Search results for wireless earbuds";
$("mr-title").textContent = isSales ? "Q3 sales leaderboard" : "Search ranking · “wireless earbuds”";
$("mr-sub").textContent = isSales ? "Account executives · updated Sep 24, 2026" : "Seller dashboard · why your listing sits where it does";
$("mr-week").hidden = !isSales;
};
$("mr-scenario").addEventListener("mv-change", (e) => { week = 0; $("mr-week").textContent = "Simulate next week"; show(e.detail.value); });
$("mr-honest").addEventListener("change", (e) => { rank.honest = e.target.checked; });
$("mr-week").addEventListener("click", (e) => {
week = week ? 0 : 1;
e.currentTarget.textContent = week ? "Back to this week" : "Simulate next week";
rank.entries = week ? nextWeek : sales.entries;
});
const log = (a) => {
const t = a.target ? ` · target ${a.target.name}` : "";
$("mr-event").replaceChildren(Object.assign(document.createElement("code"), { textContent: "mv-what-it-takes" }), ` ${a.suggestions.length} suggestion${a.suggestions.length === 1 ? "" : "s"}${t}`);
};
rank.addEventListener("mv-what-it-takes", (e) => log(e.detail));
if (rank.analysis) log(rank.analysis);
</script>
</div>Referência cultural
Branca de Neve (Schneewittchen), Irmãos Grimm (1812, fábula). Quando lhe perguntam quem é a mais bela de todas, o espelho mágico sempre responde com a verdade: diz quem é a primeira e, portanto, implicitamente, por que quem pergunta não é mais. Na interface, o ranking responde com a mesma verdade para qualquer entrada: quem está à frente e por quanto, do que é feita a distância, quais partes nunca podem mudar e quais são as menores mudanças concretas que a eliminariam.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
you | string (entry id) | The viewer's entry: tagged “You” in the list and panel, selected by default, always kept visible when limit hides rows. An entry with you: true works too. | |
selected | string (entry id) | Entry shown in the mirror panel. Defaults to you, then to #1. Reflects user selections. | |
target | "next" | "first" | number | next | Rank to aim for: the next rank up, first place, or a given rank (the lowest-scoring entry at or above it). The panel offers a Next rank / First place toggle when the two differ. |
honest | boolean | Names what can't be changed: fixed factors are hatched in every bar, labeled “Can't change” with their reason in the breakdown, and the panel and the “why #1” text say how many points of a gap or a lead come from them. Fixed factors are never suggested, with or without it. | |
limit | number | 0 | Show only the top N rows plus the selected entry, its neighbors and the viewer; hidden runs collapse into “Show 12 more” buttons. 0 shows everything. |
max-suggestions | number | 3 | How many suggestions (or slips for the leader) the panel lists. |
precision | number (0-4) | 1 | Decimals of scores and points. |
scale | number | 100 | Built-in scoring: the score of an entry that is best on every factor. Weights are shares of it. |
label | string | Ranking | Accessible name of the ranking list. |
locale | BCP 47 tag | Number and list formatting. Defaults to the closest lang attribute, then en-US. | |
heading-level | 1-6 | 3 | aria-level of the panel, suggestions and “why #1” headings. |
Properties
| Name | Tipo | Description |
|---|---|---|
factors | Factor[] | { key, label, weight (default 1), better: "higher" | "lower", min, max (normalization bounds, default: the data's), range: [lo, hi] (values reachable in a what-if, default [min, max]), step (smallest meaningful change), maxChange, cost (effort of one unit of change; default 1 / range width, so crossing the whole range costs 1), unit ("h", "%", ["deal", "deals"]; %, °, ×, k, M, B attach without a space), prefix ("$"), decimals, format(value), fixed (true or the reason, e.g. "Accrues with time on the team"), improve / slip (templates with {n}, {unit}, {value}, {label}, {from}, {to}), color, maxPoints (custom score: a factor's maximum points) }. Also read from a <script type="application/json"> child ({ factors, entries }). |
entries | Entry[] | { id, name, meta (subtitle), avatar (image URL), you, values: { [factorKey]: number } } (values may also sit directly on the entry). Replace the array (or call refresh()) to update: rows that move slide to their new place. |
score | (values, entry) => number | { total, parts } | Your own scoring instead of the built-in weighted, normalized sum. Return parts ({ [factorKey]: points }) to get the breakdown and “why #1” detail. Suggestions call it with modified values and assume it never drops when a factor gets better (binary search per factor). |
strings | Partial<Record<string, string>> | Overrides for every visible and announced text (en-US defaults with {placeholders}), e.g. behind, takesTitle, locked, whyLead, improveUp, announceSelect. |
ranking | { id, name, rank, score, parts, entry }[] | Read-only, best first; ties share a rank. |
analysis | object | Read-only: the plan shown in the panel, same shape as the mv-what-it-takes detail. |
selected / you / target / honest / limit / maxSuggestions / precision / scale / label / locale / headingLevel | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
select(id) | Shows an entry in the panel without emitting mv-select-entry. Returns false for an unknown id. |
whatItTakes(id?, { target? }) | Computes the plan for any entry and target without showing it. Returns the mv-what-it-takes detail shape. |
explainLeader() | Returns the “why is #1 first?” text. |
refresh() | Recomputes after entries or factors were mutated in place. |
Events
| Name | Description |
|---|---|
mv-select-entry | The user picked an entry (click, Enter or Space). detail: { id, entry, rank, score, previous }. Cancelable: preventDefault() keeps the current selection (e.g. other people's breakdowns are private). |
mv-what-it-takes | A new plan was computed for the panel (selection, data, target or honest mode changed). detail: { id, entry, rank, score, mode: "climb" | "lead" | "alone", target: { id, name, rank, score } | null, tie, gap, reachable, bestScore, locked: { points, factors } | null, suggestions: [{ kind: "single" | "combo" | "slip", text, score, rank, gain, cost, changes: [{ key, label, from, to, delta, text }] }] }. |
mv-preview | A suggestion is previewed (hover, keyboard focus) or pinned (press), or the preview ended. detail: { suggestion | null, pinned }. |
Content structure
| Name | Description |
|---|---|
script[type="application/json"] | Optional declarative data: { "factors": [...], "entries": [...] }. Properties set from code win. |
CSS classes
| Name | Description |
|---|---|
mv-mirror-rank-board / -list / -row / -entry | The ranking. Rows carry data-rank, data-selected and data-you; the entry button has aria-current="true" when shown in the panel. |
mv-mirror-rank-stack / -seg | Stacked contribution bar of a row, one segment per factor (data-fixed when hatched). |
mv-mirror-rank-why | “Why is #1 first?” block under the list. |
mv-mirror-rank-panel | The mirror panel (data-mode climb | lead | alone, data-preview while a suggestion is previewed). |
mv-mirror-rank-meter | Gap meter: score fill, hatched stretch to the target, target mark, preview ghost. |
mv-mirror-rank-suggestion | A suggestion button (aria-pressed when its preview is pinned, data-previewing while previewed). |
mv-mirror-rank-breakdown | The breakdown table; rows carry data-factor, data-fixed and data-changed during a preview. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-mirror-rank-color | var(--mv-accent) | Tint of the factor shades, selection, meter and toggles. |
--mv-mirror-rank-up | var(--mv-success) | Previewed gains, positive differences and rank-up badges. |
--mv-mirror-rank-down | var(--mv-danger) | Previewed losses (leader slips) and negative differences. |
Accessibility
The ranking is a real ordered list (role="list" kept despite list-style: none) named by label; each entry is a button whose name reads the rank, name, “You”, subtitle and score in order (“Rank 5 of 10: Aiko Tanaka, You, APAC · Mid-market, 68.0 pts”), with aria-current="true" on the one shown in the panel and aria-controls pointing to it. The list is one tab stop with a roving tabindex: Up/Down, Home and End move, Enter or Space selects (manual activation, so arrowing through never floods announcements). Selecting announces a polite summary: rank, score, gap and the quickest way up (or the closest risk for the leader). The panel is a labelled region with real headings (level set by heading-level); the breakdown is a <table> with a caption, column and row headers, and every bar is decorative (aria-hidden) because each row states its value, points out of the maximum and signed difference as text. Suggestions are a list of buttons (aria-pressed when pinned, described by a hint); the before/after values are written out (“Lead response 4.5 h to 1.5 h”) and the new rank is spoken. Nothing relies on color: fixed factors are hatched and labeled “Can't change” with their reason, differences carry +/− signs, the target is a tick plus “#4” text, gains and losses are striped. The Aim for toggle is a group of aria-pressed buttons; Escape clears a preview; focus is kept on the same control across re-renders. Reduced motion (OS or data-motion="reduce"): rows jump to their new place and bars change instantly. Forced colors: bars and fills switch to CanvasText/Highlight with outlines, the selection and pressed controls get Highlight outlines.