Exclusifbêta
Just Right <mv-just-right>
Retour d’ajustement personnalisé sur un groupe natif de boutons radio (tailles, offres ou configurations) : chaque option indique à l’utilisateur, zone par zone, si elle est trop petite, à la bonne taille ou trop grande pour lui. L’application fournit le profil de l’utilisateur (mensurations, taille d’équipe, usage…) et, pour chaque option et chaque dimension, la plage de valeurs du profil qui lui convient, sous forme de propriétés, d’un script JSON ou d’attributs data-fit-<key>="96-101" (plages ouvertes comme "26+" ou "<=5" comprises). Chaque option reçoit une bande d’ajustement compacte : un verdict rédigé (« Just right », « Snug in the chest, long in the sleeves », « Too small for 14 seats », « Too big overall »), puis une ligne par dimension avec une échelle à trois zones (trop petit · à la bonne taille · trop grand) où un repère montre la position de l’utilisateur, une bande de tolérance facultative pour les écarts minimes, avec des libellés personnalisés (« snug » / « relaxed », « a bit short » / « a bit long », « more than you need »), et l’écart dans l’unité adaptée (« Snug · 0.5 in »). L’option la mieux ajustée est marquée « Best fit » (ou « Closest fit » quand aucune ne convient exactement) et peut être cochée automatiquement tant que l’utilisateur n’en a pas choisi une lui-même ; les écarts peuvent être pondérés par côté, pour que payer plus que nécessaire compte moins que manquer de quelque chose. Une barre « Your measurements » intégrée résume le profil, permet de le modifier dans des champs numériques, bascule entre cm / in et kg / lb (les valeurs sont conservées en métrique, la conversion est sans perte) et peut le mémoriser sur l’appareil. Les boutons radio restent ceux du navigateur : valeur de formulaire, validation et touches fléchées ne sont pas modifiées.
| Catégorie | Formulaires |
|---|---|
| Type | Web Component (<mv-just-right>) |
| Statut | bêta |
| Keywords | exclusive, 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/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["just-right"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/just-right/just-right.js, components/just-right/just-right.css.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les 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>Référence culturelle
The Story of the Three Bears (Boucle d’or et les Trois Ours), Robert Southey (1837, conte). La visiteuse goûte chaque bouillie, essaie chaque chaise et chaque lit tour à tour, et les juge à sa propre mesure : trop chaud, trop froid, juste comme il faut ; trop dur, trop mou, juste comme il faut. Dans l’interface, chaque option est essayée à la mesure de l’utilisateur, selon ses mensurations ou son usage, zone par zone, et se dit trop petite, trop grande ou juste comme il faut, celle qui convient étant signalée et prête à être choisie.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
profile | "chest: 98, waist: 84" | JSON | Declarative 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. | |
units | metric | imperial | metric | How 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-select | boolean | Checks 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. | |
rows | all | misses | none | all | Which 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-label | string | Your measurements | Title of the profile bar (e.g. "Your usage"). |
profile-editor | inline | none | inline | none 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". |
remember | string | Storage key: the profile and units are saved in localStorage on this device and restored on the next visit (ignored when storage is unavailable). | |
locale | string | en-US | Locale used to format numbers. |
data-fit-<key> | range string | On 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-label | string | On an option: its name in verdicts and announcements (default: .mv-choice-title, then the label text). | |
data-just-right-slot / data-just-right-profile | marker | data-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-best | fit | close | small | large | mixed | unknown | Set by the component on each option (styleable): its overall fit, and data-just-right-best on the best-fitting one. |
Properties
| Name | Type | Description |
|---|---|---|
dimensions | Array<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). |
best | Verdict | null | The best-fitting enabled option (read-only), lowest miss score first. |
verdicts | Array<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 }] }. |
strings | Partial<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 / locale | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-fit | The 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-change | The 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
| Name | Description |
|---|---|
(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
| Name | Description |
|---|---|
mv-just-right-profile | The 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-strip | Fit 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-row | One 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
| Name | Default | Description |
|---|---|---|
--mv-just-right-fit | var(--mv-success) | Just-right zone, marker and “Best fit” pill tint. |
--mv-just-right-near | var(--mv-warning) | Tolerance band and near-miss marker (snug, relaxed). |
--mv-just-right-miss | var(--mv-danger) | Marker and verdict icon of a real miss (too small, too big). |
--mv-just-right-soft | var(--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.