ExklusivBeta

Just Right <mv-just-right>

Persönliches Passform-Feedback für eine native Radio-Gruppe aus Größen, Plänen oder Konfigurationen: Jede Option sagt diesem Nutzer, Zone für Zone, ob sie zu klein, genau richtig oder zu groß für ihn ist. Die App übergibt das Profil des Nutzers (Maße, Teamgröße, Nutzung…) und, pro Option und Dimension, den Bereich der Profilwerte, zu dem die Option passt, als Properties, als JSON-Skript oder als Attribute data-fit-<key>="96-101" (inklusive offener Bereiche wie "26+" oder "<=5"). Jede Option erhält einen kompakten Passform-Streifen: ein schriftliches Urteil („Just right“, „Snug in the chest, long in the sleeves“, „Too small for 14 seats“, „Too big overall“), dann eine Zeile pro Dimension mit einer Skala aus drei Zonen (too small · just right · too big), auf der eine Markierung zeigt, wo der Nutzer liegt, einem optionalen Toleranzband für knappe Abweichungen mit eigenen Wörtern („snug“ / „relaxed“, „a bit short“ / „a bit long“, „more than you need“) und der Abweichung in der passenden Einheit („Snug · 0.5 in“). Die am besten passende Option wird mit „Best fit“ markiert (oder „Closest fit“, wenn nichts genau passt) und kann automatisch ausgewählt werden, bis der Nutzer selbst eine wählt; Abweichungen lassen sich pro Seite gewichten, sodass es weniger zählt, für mehr zu zahlen, als man braucht, als nicht genug zu haben. Eine Inline-Leiste „Your measurements“ fasst das Profil zusammen, bearbeitet es in Zahlenfeldern, wechselt zwischen cm / in und kg / lb (Werte werden metrisch gespeichert, die Umrechnung ist verlustfrei) und kann es auf dem Gerät speichern. Die Radios bleiben die des Browsers: Formularwert, Validierung und Pfeiltasten bleiben unberührt.

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

Installation

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

KI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["just-right"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Kopierte Dateien (inklusive Abhängigkeiten): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/just-right/just-right.js, components/just-right/just-right.css.

Verwendung

Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:

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

Kulturelle Referenz

Goldlöckchen und die drei Bären, Robert Southey (1837, Fabel). Die Besucherin probiert nacheinander jeden Brei, jeden Stuhl und jedes Bett und misst sie an sich selbst: zu heiß, zu kalt, genau richtig; zu hart, zu weich, genau richtig. In der UI wird jede Option an den eigenen Maßen oder der Nutzung des Nutzers gemessen, Zone für Zone, und sagt zu klein, zu groß oder genau richtig; die passende ist markiert und bereit zur Auswahl.

API

Attributes

NameTypDefaultDescription
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

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

Diese Seite wurde mit KI übersetzt. Übersetzungsfehler melden