Exclusivebeta

Just Right — <mv-just-right>

Personal fit feedback on a native radio group of sizes, plans or configurations: every option tells this user, zone by zone, whether it is too small, just right or too big for them. The app gives the user's profile (measurements, team size, usage…) and, per option and dimension, the range of profile values that option suits, as properties, a JSON script or data-fit-<key>="96-101" attributes (open ranges like "26+" or "<=5" included). Each option gets a compact fit strip: a written verdict (“Just right”, “Snug in the chest, long in the sleeves”, “Too small for 14 seats”, “Too big overall”), then one row per dimension with a three-zone scale (too small · just right · too big) where a marker shows where the user sits, an optional tolerance band for near misses with custom words (“snug” / “relaxed”, “a bit short” / “a bit long”, “more than you need”) and the gap in the right unit (“Snug · 0.5 in”). The best-fitting option is marked “Best fit” (or “Closest fit” when nothing fits exactly) and can be checked automatically until the user picks one themselves; misses can be weighted per side, so paying for more than you need counts less than not having enough. An inline “Your measurements” bar sums up the profile, edits it in number fields, switches cm / in and kg / lb (values are kept in metric, conversion is lossless) and can remember it on the device. The radios stay the browser's: form value, validation and arrow keys are untouched.

CategoryForms
TypeWeb 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

Avoid when

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["just-right"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/just-right/just-right.js, components/just-right/just-right.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<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>

Cultural reference

The Story of the Three Bears — Robert Southey (1837, fable). The visitor tries each porridge, chair and bed in turn and judges it against herself: too hot, too cold, just right; too hard, too soft, just right. In the UI every option is tried against the user's own measurements or usage, zone by zone, and says too small, too big or just right, with the one that fits marked and ready to pick.

API

Attributes

NameTypeDefaultDescription
profile"chest: 98, waist: 84" | JSONDeclarative starting profile, read once on first connection, in base units (cm for length, kg for weight, the dimension's own unit otherwise). A remembered profile and the profile property take precedence.
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

NameTypeDescription
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).
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.