Exclusivobeta

Just Right <mv-just-right>

Indicação personalizada de caimento em um grupo de rádios nativo de tamanhos, planos ou configurações: cada opção diz a este usuário, zona por zona, se ela fica pequena, na medida ou grande para ele. O app fornece o perfil do usuário (medidas, tamanho da equipe, uso…) e, por opção e dimensão, a faixa de valores de perfil a que essa opção atende, como propriedades, um script JSON ou atributos data-fit-<key>="96-101" (faixas abertas como "26+" ou "<=5" incluídas). Cada opção ganha uma faixa de caimento compacta: um veredito escrito (“Just right”, “Snug in the chest, long in the sleeves”, “Too small for 14 seats”, “Too big overall”) e depois uma linha por dimensão com uma escala de três zonas (pequeno · na medida · grande) em que um marcador mostra onde o usuário está, uma faixa de tolerância opcional para os quase acertos com palavras personalizadas (“snug” / “relaxed”, “a bit short” / “a bit long”, “more than you need”) e a diferença na unidade certa (“Snug · 0.5 in”). A opção de melhor caimento é marcada como “Best fit” (ou “Closest fit” quando nenhuma serve exatamente) e pode ser selecionada automaticamente até que o usuário escolha uma por conta própria; os desvios podem ter peso por lado, para que pagar por mais do que você precisa conte menos do que ficar faltando. Uma barra inline “Your measurements” resume o perfil, o edita em campos numéricos, alterna entre cm / in e kg / lb (os valores são guardados no sistema métrico e a conversão não perde precisão) e pode lembrá-lo no dispositivo. Os rádios continuam sendo os do navegador: o valor do formulário, a validação e as teclas de seta ficam intactos.

CategoriaFormulários
TipoWeb Component (<mv-just-right>)
Statusbeta
Keywordsexclusive, culture, size, size-guide, size-chart, fit, fit-finder, recommendation, sizing, plan-picker, configurator, e-commerce, apparel, measurements, units, imperial, metric, radio, comparison

When to use

  • A size picker (apparel, shoes, rings, bike frames) should tell each shopper how every size fits them, zone by zone, before they order
  • A plan or instance picker knows the customer's usage and should say which tier is too small, just right or more than they need
  • Returns for the wrong size are costly and shoppers need a concrete reason to trust the recommended size
  • A furniture or equipment configurator must check each option against the space or limits the user entered

Avoid when

  • The user has not given any needs yet and must first be asked questions to shortlist a large catalog → use Narrow Down instead
  • The choice is subjective (style, taste) with no measurable dimension to compare against the user → use Lifelines instead
  • Plans are compared on features and prices for everyone, not against one customer's usage → use Pricing instead

Instalação

node scripts/add.mjs just-right --out ./src/marvelous

Agente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["just-right"], 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, components/just-right/just-right.js, components/just-right/just-right.css.

Uso

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:

<div id="jr-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #jr-demo { display:grid; gap:1.25rem; align-content:start }
    #jr-demo .jr-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #jr-demo .jr-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:1.125rem 1.25rem 1rem; border-bottom:1px solid var(--mv-border) }
    #jr-demo .jr-head h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
    #jr-demo .jr-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #jr-demo .jr-price { font-weight:650; font-size:1.0625rem; font-variant-numeric:tabular-nums }
    #jr-demo .jr-body { padding:1.125rem 1.25rem 1.25rem }
    #jr-demo .mv-choice-group legend { font-size:.8125rem; color:var(--mv-fg-muted) }
    #jr-demo .mv-choice-title { font-size:.9375rem }
    #jr-demo .mv-radio-meta { font-size:.75rem; font-weight:500; color:var(--mv-fg-muted) }
    #jr-demo .jr-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
    #jr-demo .jr-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
    #jr-demo .jr-actions > span { color:var(--mv-fg-muted); font-size:.75rem; margin-inline-end:.125rem }
    #jr-demo .jr-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); flex:1 1 100% }
    #jr-demo .mv-choice { font-size:.8125rem }
    #jr-demo .jr-head b { color:var(--mv-fg); font-weight:600 }
    #jr-demo .jr-bike .mv-choice-card { padding:.75rem .875rem }
  </style>

  <!-- Main: apparel sizes, imperial units, inline profile editor, best fit auto-selected -->
  <section class="jr-card" aria-labelledby="jr-coat-title">
    <header class="jr-head">
      <div>
        <h3 id="jr-coat-title">Harbor Field Jacket · Olive</h3>
        <p>Regular fit, waxed cotton. Free returns within 30 days.</p>
      </div>
      <span class="jr-price">$168.00</span>
    </header>
    <div class="jr-body">
      <mv-just-right id="jr-coat" units="imperial" auto-select profile="chest: 98, waist: 84, sleeve: 83.5">
        <script type="application/json">
          {
            "dimensions": [
              { "key": "chest", "label": "Chest", "unit": "length", "tolerance": 2.5,
                "labels": { "under": "tight", "nearUnder": "snug", "fit": "just right", "nearOver": "relaxed", "over": "loose" } },
              { "key": "waist", "label": "Waist", "unit": "length", "tolerance": 3,
                "labels": { "under": "tight", "nearUnder": "snug", "fit": "just right", "nearOver": "relaxed", "over": "loose" } },
              { "key": "sleeve", "label": "Sleeve", "name": "sleeves", "unit": "length", "tolerance": 1.5,
                "hint": "Shoulder to wrist, arm relaxed",
                "labels": { "under": "short", "nearUnder": "a bit short", "fit": "just right", "nearOver": "a bit long", "over": "long" } }
            ],
            "options": {
              "s":  { "chest": "86-93",   "waist": "71-78", "sleeve": "80-82" },
              "m":  { "chest": "94-100",  "waist": "79-86", "sleeve": "82-84.5" },
              "l":  { "chest": "101-107", "waist": "87-94", "sleeve": "84.5-86.5" },
              "xl": { "chest": "108-115", "waist": "95-102", "sleeve": "86.5-88.5" }
            }
          }
        </script>
        <fieldset class="mv-choice-group" data-layout="grid" style="--mv-choice-min:19rem">
          <legend>Size</legend>
          <label class="mv-choice-card">
            <input type="radio" class="mv-radio" name="jr-size" value="s">
            <span class="mv-choice-text"><span class="mv-choice-title">S</span><span class="mv-choice-description">US 34-36</span></span>
            <span class="mv-radio-meta">In stock</span>
          </label>
          <label class="mv-choice-card">
            <input type="radio" class="mv-radio" name="jr-size" value="m">
            <span class="mv-choice-text"><span class="mv-choice-title">M</span><span class="mv-choice-description">US 38-40</span></span>
            <span class="mv-radio-meta">In stock</span>
          </label>
          <label class="mv-choice-card">
            <input type="radio" class="mv-radio" name="jr-size" value="l">
            <span class="mv-choice-text"><span class="mv-choice-title">L</span><span class="mv-choice-description">US 42-44</span></span>
            <span class="mv-radio-meta">3 left</span>
          </label>
          <label class="mv-choice-card">
            <input type="radio" class="mv-radio" name="jr-size" value="xl">
            <span class="mv-choice-text"><span class="mv-choice-title">XL</span><span class="mv-choice-description">US 46-48</span></span>
            <span class="mv-radio-meta">In stock</span>
          </label>
        </fieldset>
      </mv-just-right>
    </div>
    <div class="jr-controls">
      <span class="jr-actions" role="group" aria-label="Try another shopper">
        <span>Try another shopper:</span>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-profile='{"chest":98,"waist":84,"sleeve":83.5}'>Daniel</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-profile='{"chest":104.5,"waist":85,"sleeve":85.5}'>Kofi, broad chest</button>
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" data-profile='{"chest":91,"waist":80,"sleeve":85}'>Lena, long arms</button>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="jr-clear">Clear</button>
      </span>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="jr-auto" checked> Auto-select best fit</label>
      <p class="jr-log" id="jr-log" aria-live="off">-</p>
    </div>
  </section>

  <!-- Plan sizing: count units, open-ended ranges, "more than you need" is a soft miss -->
  <section class="jr-card" aria-labelledby="jr-plan-title">
    <header class="jr-head">
      <div>
        <h3 id="jr-plan-title">Choose a plan for Brightline Analytics</h3>
        <p>Sized against what your workspace used last month. Billed yearly.</p>
      </div>
    </header>
    <div class="jr-body">
      <mv-just-right id="jr-plan" profile-label="Your usage">
        <fieldset class="mv-choice-group" data-layout="grid" style="--mv-choice-min:15rem">
          <legend>Plan</legend>
          <label class="mv-choice-card">
            <input type="radio" class="mv-radio" name="jr-plan" value="starter">
            <span class="mv-choice-text"><span class="mv-choice-title">Starter</span><span class="mv-choice-description">5 seats · 10 GB · 3 projects</span></span>
            <span class="mv-radio-meta">$0</span>
          </label>
          <label class="mv-choice-card">
            <input type="radio" class="mv-radio" name="jr-plan" value="team" checked>
            <span class="mv-choice-text"><span class="mv-choice-title">Team</span><span class="mv-choice-description">25 seats · 250 GB · 20 projects</span></span>
            <span class="mv-radio-meta">$19 / seat</span>
          </label>
          <label class="mv-choice-card">
            <input type="radio" class="mv-radio" name="jr-plan" value="enterprise">
            <span class="mv-choice-text"><span class="mv-choice-title">Enterprise</span><span class="mv-choice-description">From 26 seats · 250 GB+ · Unlimited</span></span>
            <span class="mv-radio-meta">Custom</span>
          </label>
        </fieldset>
      </mv-just-right>
    </div>
  </section>

  <!-- Compact: bike frames, declarative data-fit-* attributes, only the misses are listed, no editor -->
  <section class="jr-card" aria-labelledby="jr-bike-title">
    <header class="jr-head">
      <div>
        <h3 id="jr-bike-title">Frame size · Crest AL Gravel</h3>
        <p>For a rider <b>178 cm</b> tall with an <b>82 cm</b> inseam, from your last bike fitting. Only the zones that miss are listed.</p>
      </div>
    </header>
    <div class="jr-body jr-bike">
      <mv-just-right id="jr-bike" profile="height: 178, inseam: 82" profile-editor="none" rows="misses" auto-select>
        <script type="application/json">
          { "dimensions": [
              { "key": "height", "label": "Height", "name": "reach", "unit": "length", "tolerance": 3,
                "labels": { "under": "too small", "nearUnder": "a little small", "fit": "just right", "nearOver": "a little long", "over": "too long" } },
              { "key": "inseam", "label": "Inseam", "name": "standover", "unit": "length", "tolerance": 2,
                "labels": { "under": "too low", "nearUnder": "a little low", "fit": "just right", "nearOver": "a little high", "over": "too high" } }
          ] }
        </script>
        <fieldset class="mv-choice-group" data-layout="grid" style="--mv-choice-min:13rem">
          <legend class="mv-sr-only">Frame size</legend>
          <label class="mv-choice-card" data-fit-height="158-168" data-fit-inseam="72-78">
            <input type="radio" class="mv-radio" name="jr-frame" value="52"><span class="mv-choice-text"><span class="mv-choice-title">52 cm</span></span>
          </label>
          <label class="mv-choice-card" data-fit-height="166-175" data-fit-inseam="77-81">
            <input type="radio" class="mv-radio" name="jr-frame" value="54"><span class="mv-choice-text"><span class="mv-choice-title">54 cm</span></span>
          </label>
          <label class="mv-choice-card" data-fit-height="175-183" data-fit-inseam="80-86">
            <input type="radio" class="mv-radio" name="jr-frame" value="56"><span class="mv-choice-text"><span class="mv-choice-title">56 cm</span></span>
          </label>
          <label class="mv-choice-card" data-fit-height="183-192" data-fit-inseam="85-91">
            <input type="radio" class="mv-radio" name="jr-frame" value="58"><span class="mv-choice-text"><span class="mv-choice-title">58 cm</span></span>
          </label>
        </fieldset>
      </mv-just-right>
    </div>
  </section>

  <script type="module">
    const coat = document.getElementById("jr-coat");
    const log = document.getElementById("jr-log");
    const say = (text) => { log.textContent = text; };

    coat.addEventListener("mv-fit", (e) => {
      const b = e.detail.best;
      say(b ? `mv-fit · best: ${b.label} (${b.verdict})` : "mv-fit · no measurements yet");
    });
    coat.addEventListener("mv-profile-change", (e) => {
      const p = e.detail.profile;
      say(`mv-profile-change · ${e.detail.source} · ${Object.keys(p).length} values in cm, showing ${e.detail.units}`);
    });
    for (const btn of document.querySelectorAll("#jr-demo [data-profile]")) {
      btn.addEventListener("click", () => { coat.profile = JSON.parse(btn.dataset.profile); coat.selectBest(); });
    }
    document.getElementById("jr-clear").addEventListener("click", () => coat.setProfile({ chest: null, waist: null, sleeve: null }));
    document.getElementById("jr-auto").addEventListener("change", (e) => { coat.autoSelect = e.target.checked; });

    // Plans: the app knows the workspace usage and sizes each plan against it.
    const plan = document.getElementById("jr-plan");
    plan.strings = { verdictAllUnder: "", verdictAllOver: "" };
    const soft = { under: "too small", fit: "just right", over: "more than you need" };
    plan.dimensions = [
      { key: "seats", label: "Seats", unit: "seats", unitOne: "seat", labels: soft, weight: 2, penalty: { over: 0.35 },
        phrase: { under: "Too small for {value}", over: "More seats than you need" } },
      { key: "storage", label: "Storage", unit: "GB", labels: soft, penalty: { over: 0.35 }, step: 1,
        phrase: { under: "Not enough room for {value}", over: "More storage than you need" } },
      { key: "projects", label: "Projects", unit: "projects", unitOne: "project", labels: soft, penalty: { over: 0.35 },
        phrase: { under: "Too few projects for {value}", over: "More projects than you need" } },
    ];
    plan.options = {
      starter: { seats: "<=5", storage: "<=10", projects: "<=3" },
      team: { seats: "6-25", storage: "<=250", projects: "<=20" },
      enterprise: { seats: "26+", storage: "250+", projects: "0+" },
    };
    plan.profile = { seats: 14, storage: 120, projects: 9 };
  </script>
</div>

Referência cultural

The Story of the Three Bears (Cachinhos Dourados e os Três Ursos), Robert Southey (1837, fábula). A visitante experimenta cada mingau, cada cadeira e cada cama, uma de cada vez, e as julga em relação a si mesma: quente demais, frio demais, no ponto; duro demais, mole demais, no ponto. Na interface, cada opção é experimentada em relação às medidas ou ao uso do próprio usuário, zona por zona, e diz se fica pequena, grande ou na medida, com a que serve marcada e pronta para escolher.

API

Attributes

NameTipoDefaultDescription
profile"chest: 98, waist: 84" | JSONDeclarative profile in base units (cm for length, kg for weight, the dimension's own unit otherwise). On first connection a remembered profile and the profile property take precedence; changing the attribute later replaces the profile and re-evaluates.
unitsmetric | imperialmetricHow length and weight values are shown and typed: cm / kg or in / lb. Values are always stored in metric. Changing it re-renders every row, is announced, and emits mv-profile-change (source "units").
auto-selectbooleanChecks the best-fitting option (firing input and change) on load and after each profile change, until the user picks an option themselves. Cancel mv-fit to skip one auto-selection.
rowsall | misses | noneallWhich dimension rows each option shows: all of them, only the ones that do not fit (compact lists), or none (verdict only). The accessible description always lists every row.
profile-labelstringYour measurementsTitle of the profile bar (e.g. "Your usage").
profile-editorinline | noneinlinenone hides the built-in profile bar when your app edits the profile elsewhere (set the profile property or call setProfile()).
unit-toggle"true" | "false"true"false" hides the cm / in toggle. It only appears when a dimension has unit "length" or "weight".
rememberstringStorage key: the profile and units are saved in localStorage on this device and restored on the next visit (ignored when storage is unavailable).
localestringen-USLocale used to format numbers.
data-fit-<key>range stringOn an option (its label, or an element with data-option): the range of the user's value this option suits for dimension <key>, in base units. "96-101", "7" (exact), "26+" / ">=26" (open top), "<=5" / "up to 5" (open bottom). The options property overrides it.
data-labelstringOn an option: its name in verdicts and announcements (default: .mv-choice-title, then the label text).
data-just-right-slot / data-just-right-profilemarkerdata-just-right-slot inside an option: where its fit strip goes (default: full width at the bottom of a .mv-choice-card, else in .mv-choice-text). data-just-right-profile anywhere inside: where the profile bar goes (default: first child).
data-just-right / data-just-right-bestfit | close | small | large | mixed | unknownSet by the component on each option (styleable): its overall fit, and data-just-right-best on the best-fitting one.

Properties

NameTipoDescription
dimensionsArray<Dimension>Dimension = { key, label?, name? (word used in verdicts, default label in lower case), unit? ("length" | "weight" | any word such as "seats" or "GB"), unitOne? (singular), tolerance? (number or { under, over }: near-miss band in base units), labels? ({ under, nearUnder, fit, nearOver, over }), phrase? (verdict template or { under, over } with {state}, {name}, {value}, {amount}), weight? (importance, default 1), penalty? ({ under, over }, default 1: below 0.5 a miss is shown as soft), step?, min?, max?, hint? (help under the field), editable? (false hides its field) }. Can also come from a child <script type="application/json">{ "dimensions": [...], "options": {...}, "profile": {...}, "strings": {...} }</script>. Keys found only in data-fit-* attributes become plain dimensions.
options{ [radioValue]: { [key]: range } }Ranges per option and dimension; a range is [min, max], { min, max }, a number or a range string (see data-fit-<key>). Overrides data-fit-* attributes.
profile{ [key]: number }The user's values in base units. Setting it replaces the profile and re-evaluates (no mv-profile-change: that event reports the user's own edits). A string is parsed like the attribute ("chest: 98, waist: 84" or JSON).
bestVerdict | nullThe best-fitting enabled option (read-only), lowest miss score first.
verdictsArray<Verdict>Every option's evaluation (read-only). Verdict = { value, label, element, input, fit, verdict, score, best, rows: [{ key, label, state ("under" | "near-under" | "fit" | "near-over" | "over" | "unknown"), side ("under" = the option is too small for the user, "over" = too big), amount, value, min, max, text }] }.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (keys: profileTitle, profileEmpty, edit, done, unitsLabel, metricLength, imperialLength, metricBoth, imperialBoth, announceUnits, under, nearUnder, fit, nearOver, over, phrase, verdictFit, verdictUnknown, verdictAllUnder, verdictAllOver (empty string disables them), verdictMore, best, closest, rowFit, rowMiss, rowUnknown, rowUnknownShort, announceBest, announceBestSelected, announceNone). English defaults.
units / autoSelect / rows / profileLabel / profileEditor / unitToggle / remember / localereflectedMirror the attributes.

Methods

NameDescription
setProfile(values, { units? })Merges values (base units; null removes one) into the profile, saves it when remember is set, emits mv-profile-change (source "api") and re-evaluates.
selectBest()Checks the best-fitting option now (fires input and change). Returns false when there is none.
refresh()Re-reads the radios and data-fit-* attributes. Rarely needed: added or removed options are picked up automatically.

Events

NameDescription
mv-fitThe evaluation changed (load, profile, units, options). detail: { best, verdicts, reason: "init" | "profile" | "options" | "api" }. Cancelable: preventDefault() skips the auto-selection that would follow.
mv-profile-changeThe user edited the profile, switched units, or setProfile() was called. detail: { profile (base units), units, key, value, source: "editor" | "units" | "api" }. Save it to the account here.

Content structure

NameDescription
(content)A native radio group (fieldset.mv-choice-group with .mv-choice-card labels, or any radios). Each option keeps its markup; the strip is appended to it and marked aria-hidden, its text being exposed as the radio's description instead.

CSS classes

NameDescription
mv-just-right-profileThe profile bar: -profile-head, -profile-title, -summary (.mv-just-right-chip per value), -units (segmented cm / in on native radios), -edit (toggle button), -fields (.mv-just-right-field > label, -control > -input + -suffix, -hint). data-editing while open.
mv-just-right-stripFit strip inside each option: -head (.mv-just-right-verdict[data-fit] with -verdict-icon and -verdict-text, .mv-just-right-best[data-exact]) and -rows.
mv-just-right-rowOne dimension (display: contents in a 3-column grid): -row-label, .mv-just-right-scale (three .mv-just-right-zone[data-zone], data-near-under / data-near-over when a tolerance band exists, .mv-just-right-pin > .mv-just-right-marker positioned by --_x in %), -row-state. data-state and data-soft on the row.

CSS variables

NameDefaultDescription
--mv-just-right-fitvar(--mv-success)Just-right zone, marker and “Best fit” pill tint.
--mv-just-right-nearvar(--mv-warning)Tolerance band and near-miss marker (snug, relaxed).
--mv-just-right-missvar(--mv-danger)Marker and verdict icon of a real miss (too small, too big).
--mv-just-right-softvar(--mv-info)Marker of a soft miss (penalty below 0.5, e.g. more than you need).

Accessibility

The options stay native radios in their fieldset: Tab reaches the group, arrow keys move the choice, the form submits the value. Each fit strip is aria-hidden (so it never pollutes the radio's name) and its content is exposed as the radio's accessible description through aria-describedby, as sentences: “Snug in the chest. Chest: snug by 0.5 inches. Waist: just right. Sleeve: just right. Closest fit.” (amounts spelled with full unit words: inches, centimeters, pounds, seats). Nothing relies on color: every row shows the state in words, the marker's position on the too small · just right · too big scale carries the same information, the verdict has a distinct icon per outcome (check, approximately, inward or outward arrows), and “Best fit” / “Closest fit” is written. After a profile edit, the best fit is announced once in a polite live region after typing pauses (“Best fit: M, selected. Just right.”); a unit switch is announced (“Showing inches and pounds.”). The profile bar is a labelled group: the Edit / Done button has aria-expanded and aria-controls, every number field has a real label that includes the unit (“Chest (inches)”), Escape in a field closes the editor and returns focus to Edit, and a value being typed is never rewritten under the caret. The unit toggle is a radiogroup of native radios (arrow keys) with a visible focus ring. Auto-selection never steals focus and stops as soon as the user picks an option. Reduced motion (OS or data-motion="reduce"): markers jump instead of sliding. Forced colors: zones get system borders, the just-right zone uses Highlight, the marker CanvasText.

Esta página foi traduzida com IA. Informar um problema de tradução