Exclusivobeta

Just Right <mv-just-right>

Indicación personal de ajuste sobre un grupo de radios nativo de tallas, planes o configuraciones: cada opción le dice a este usuario, zona por zona, si le queda pequeña, justa o grande. La app proporciona el perfil del usuario (medidas, tamaño del equipo, uso…) y, por opción y dimensión, el intervalo de valores de perfil al que se adapta esa opción, como propiedades, un script JSON o atributos data-fit-<key>="96-101" (se admiten intervalos abiertos como "26+" o "<=5"). Cada opción recibe una tira de ajuste compacta: un veredicto escrito (“Just right”, “Snug in the chest, long in the sleeves”, “Too small for 14 seats”, “Too big overall”) y luego una fila por dimensión con una escala de tres zonas (pequeña · justa · grande) donde un marcador muestra dónde se sitúa el usuario, una banda de tolerancia opcional para los casi aciertos con palabras personalizadas (“snug” / “relaxed”, “a bit short” / “a bit long”, “more than you need”) y la diferencia en la unidad adecuada (“Snug · 0.5 in”). La opción que mejor se ajusta se marca como “Best fit” (o “Closest fit” cuando ninguna se ajusta exactamente) y puede seleccionarse automáticamente hasta que el usuario elija una por sí mismo; los desajustes pueden ponderarse por lado, para que pagar por más de lo que necesitas cuente menos que quedarte corto. Una barra en línea “Your measurements” resume el perfil, lo edita en campos numéricos, cambia entre cm / in y kg / lb (los valores se guardan en el sistema métrico y la conversión no pierde precisión) y puede recordarlo en el dispositivo. Los radios siguen siendo los del navegador: el valor del formulario, la validación y las teclas de flecha no se tocan.

CategoríaFormularios
TipoWeb Component (<mv-just-right>)
Estadobeta
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

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["just-right"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): 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

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables 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>

Referencia cultural

The Story of the Three Bears (Ricitos de Oro y los tres osos), Robert Southey (1837, fábula). La visitante prueba cada gacha, cada silla y cada cama por turno y las juzga en relación consigo misma: demasiado caliente, demasiado fría, justa; demasiado dura, demasiado blanda, justa. En la interfaz, cada opción se prueba frente a las medidas o el uso del propio usuario, zona por zona, y dice si queda pequeña, grande o justa, con la que se ajusta marcada y lista para elegir.

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 se tradujo con IA. Informar de un problema de traducción