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.
| Category | Forms |
|---|---|
| Type | Web Component (<mv-just-right>) |
| Status | beta |
| 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
Install
node scripts/add.mjs just-right --out ./src/marvelousAI 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
| Name | Type | Default | Description |
|---|---|---|---|
profile | "chest: 98, waist: 84" | JSON | Declarative 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. | |
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). |
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.