Exclusivebeta
Strips — <mv-strips>
Combinatorial composer: a result (ad copy, a product configuration, a notification, a menu) is assembled from independent strips stacked like one page cut into slices, each strip a slot whose interchangeable alternatives flip on their own with a physical page-lift animation (arrows, vertical drag or horizontal swipe, tap, wheel with a scroll-intent guard, Up/Down/Home/End on the focused strip). Shuffle flips every unpinned strip in a staggered cascade and only ever lands on a valid combination; pins keep the parts you like. The bar shows the live number of combinations (exact even with rules, plus how many remain with the current pins) and the current combination’s shareable code (“3-1-4-2”, the value). Optional pairwise rules (forbid / require) grey out incompatible alternatives with the reason, explain the conflict in full on the strip just flipped (its counterpart points back to it) and set form validity; an optional measure sums the character count against a max or the price from per-option deltas as you flip. Form-associated: submits the code plus name[strip]=value for every strip; cancelable mv-flip and mv-shuffle let the app veto.
| Category | Forms |
|---|---|
| Type | Web Component (<mv-strips>) |
| Status | beta |
| Also installs | button |
| Keywords | exclusive, culture, composer, configurator, combinatorial, combinations, variants, a-b-testing, copywriting, product-options, shuffle, randomize, lock, pin, rules, constraints, compatibility, price, character-count, spinbutton, form-associated, flip |
When to use
- A marketer tries hook, benefit, proof and call-to-action variants and saves the best combinations as A/B codes
- A product configurator combines frame, color, size and finish, with incompatible pairs and a running price
- Templates (notifications, emails, meal plans, avatars) are built from a few interchangeable parts and should be shuffled for ideas
- A combination must be shareable or restorable as a short code in a URL, a spreadsheet or a support ticket
Avoid when
- Each part has dozens of options users know by name and would rather type or search → use Combobox instead
- The parts are scalar values such as hours, minutes or a date, picked with touch momentum → use Wheel Picker instead
- Choices depend on each other in sequence (later steps change with earlier answers) → use Finger Hold instead
Install
node scripts/add.mjs strips --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["strips"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/form.js, core/motion.js, core/pointer.js, components/strips/strips.js, components/strips/strips.css, components/button/button.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="st-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#st-demo { display:grid; gap:2rem; align-content:start }
#st-demo .st-case { display:grid; grid-template-columns:minmax(0,1fr) 19rem; gap:1.25rem; align-items:start }
#st-demo .st-head { grid-column:1 / -1; display:flex; align-items:end; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
#st-demo .st-head h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#st-demo .st-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#st-demo .st-side { display:grid; gap:.875rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs); font-size:.8125rem }
#st-demo .st-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#st-demo .st-row { display:flex; align-items:center; justify-content:space-between; gap:.5rem }
#st-demo .st-actions { display:flex; gap:.5rem; flex-wrap:wrap }
/* Ad preview */
#st-demo .st-ad { display:grid; gap:.625rem; padding:.875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#st-demo .st-ad-top { display:flex; align-items:center; gap:.5rem }
#st-demo .st-ad-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:50%; background:var(--mv-accent); color:var(--mv-fg-on-accent); font-size:.6875rem; font-weight:700 }
#st-demo .st-ad-name { display:grid; line-height:1.2 }
#st-demo .st-ad-name b { font-size:.8125rem }
#st-demo .st-ad-name span { color:var(--mv-fg-subtle); font-size:.6875rem }
#st-demo .st-ad-text { margin:0; font-size:.8125rem; line-height:1.5; color:var(--mv-fg) }
#st-demo .st-ad-card { display:flex; align-items:center; justify-content:space-between; gap:.5rem; padding:.5rem .625rem; border-radius:var(--mv-radius-md); background:var(--mv-surface); border:1px solid var(--mv-border); font-size:.75rem }
#st-demo .st-ad-card span { color:var(--mv-fg-muted) }
#st-demo .st-saved { list-style:none; margin:0; padding:0; display:grid; gap:.25rem }
#st-demo .st-saved button { display:flex; width:100%; align-items:center; gap:.5rem; padding:.375rem .5rem; border:0; border-radius:var(--mv-radius-md); background:transparent; color:inherit; font:inherit; font-size:.75rem; text-align:start; cursor:pointer }
#st-demo .st-saved button:hover { background:var(--mv-bg-muted) }
#st-demo .st-saved button:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#st-demo .st-saved code { flex:none; padding:.0625rem .375rem; border-radius:var(--mv-radius-sm); background:var(--mv-bg-muted); font:500 .6875rem/1.4 var(--mv-font-mono) }
#st-demo .st-saved span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--mv-fg-muted) }
#st-demo .st-empty { color:var(--mv-fg-subtle); font-size:.75rem }
/* Frame configurator */
#st-demo .st-sw { flex:none; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-sm); background:var(--c); box-shadow:inset 0 0 0 1px color-mix(in oklab, var(--mv-fg) 14%, transparent) }
#st-demo .st-opt { display:grid; min-width:0; line-height:1.25 }
#st-demo .st-opt small { color:var(--mv-fg-muted); font-size:.75rem; font-weight:400 }
#st-demo .st-stage { display:grid; place-items:center; height:13rem; border-radius:var(--mv-radius-lg); background:linear-gradient(180deg, color-mix(in oklab, var(--mv-bg-muted) 70%, var(--mv-surface)), var(--mv-bg-muted)) }
#st-demo .st-frame { --fw:#b88a57; --mt:#f6f3ec; --w:6rem; --h:7.5rem; display:grid; box-sizing:border-box; width:var(--w); height:var(--h); padding:.4375rem; background:var(--fw); box-shadow:0 10px 24px -8px rgb(0 0 0 / .35), inset 0 0 0 1px rgb(0 0 0 / .12); transition:width .4s var(--mv-ease-out), height .4s var(--mv-ease-out), background-color .3s }
#st-demo .st-mat { display:grid; padding:var(--mp, 12%); background:var(--mt); transition:padding .3s, background-color .3s }
#st-demo .st-art { background:linear-gradient(160deg, #2f5d7c 0%, #6a9bb5 38%, #e8c28a 70%, #c0643b 100%); position:relative; overflow:hidden }
#st-demo .st-art::after { content:""; position:absolute; inset:0; background:linear-gradient(115deg, rgb(255 255 255 / var(--gl, .22)) 0 30%, transparent 30%) }
#st-demo .st-price { font-size:1.5rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#st-demo .st-log { margin:0; min-height:2.5rem; padding:.5rem .625rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font:.6875rem/1.5 var(--mv-font-mono); white-space:pre-wrap; word-break:break-all }
@media (max-width:52rem) { #st-demo .st-case { grid-template-columns:minmax(0,1fr) } }
</style>
<!-- 1 · Marketing: compose an ad description from interchangeable parts, with a character limit -->
<section class="st-case" aria-labelledby="st-ad-title">
<div class="st-head">
<div>
<h3 id="st-ad-title">Ad copy composer</h3>
<p>Four parts, each with its own alternatives. Flip a strip, pin the parts you like, shuffle the rest.</p>
</div>
</div>
<mv-strips id="st-ad" name="ad" label="Ad description" measure="length" max="150" lockable value="4-3-1-1">
<div data-strip="hook" data-label="Hook">
<div data-value="slow">Stop losing deals to slow follow-ups.</div>
<div data-value="cold">Your leads go cold in five minutes.</div>
<div data-value="calls">Every missed call is a missed sale.</div>
<div data-value="admin">Reps lose 9 hours a week to admin.</div>
<div data-value="speed">Speed wins deals.</div>
<div data-value="inbox">Your inbox is not a CRM.</div>
</div>
<div data-strip="benefit" data-label="Benefit">
<div data-value="reply">Fieldnote replies to every new lead in under a minute,</div>
<div data-value="log">Fieldnote logs calls, emails and meetings for you,</div>
<div data-value="route">Fieldnote routes each lead to the right rep instantly,</div>
<div data-value="book">Fieldnote drafts the follow-up and books the meeting,</div>
<div data-value="todo">Fieldnote turns your pipeline into a to-do list,</div>
</div>
<div data-strip="proof" data-label="Proof">
<div data-value="teams">trusted by 4,000 sales teams.</div>
<div data-value="rating">rated 4.8 out of 5 by 1,200 reviewers.</div>
<div data-value="meetings">with 31% more meetings booked on average.</div>
<div data-value="setup">and it’s set up in an afternoon.</div>
</div>
<div data-strip="cta" data-label="Call to action" data-locked>
<div data-value="trial">Start your free trial.</div>
<div data-value="demo">Book a 15-minute demo.</div>
<div data-value="days">Try it free for 14 days.</div>
<div data-value="own">See it on your own leads.</div>
</div>
</mv-strips>
<aside class="st-side" aria-label="Ad preview">
<div class="st-row"><span class="st-k">Preview</span><span class="mv-badge" data-variant="success" data-shape="pill" id="st-ad-status">Within limit</span></div>
<div class="st-ad">
<div class="st-ad-top">
<span class="st-ad-logo" aria-hidden="true">Fn</span>
<span class="st-ad-name"><b>Fieldnote CRM</b><span>Sponsored</span></span>
</div>
<p class="st-ad-text" id="st-ad-text"></p>
<div class="st-ad-card"><b>fieldnote.app</b><span>Sales CRM for small teams</span></div>
</div>
<div class="st-actions">
<button type="button" class="mv-button" data-size="sm" id="st-ad-save">Save as variant</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="st-ad-copy">Copy text</button>
</div>
<div style="display:grid;gap:.375rem">
<span class="st-k">A/B variants</span>
<ol class="st-saved" id="st-ad-saved"><li class="st-empty">Saved codes appear here. Click one to restore it.</li></ol>
</div>
<span class="mv-sr-only" role="status" id="st-ad-live"></span>
</aside>
</section>
<!-- 2 · Commerce: a framed-print configurator with prices and compatibility rules, inside a form -->
<form class="st-case" id="st-form" aria-labelledby="st-fr-title">
<div class="st-head">
<div>
<h3 id="st-fr-title">Framed print configurator</h3>
<p>“Harbor at Dusk”, giclée print. Prices add up live; combinations that can’t be made are greyed out with the reason.</p>
</div>
<label class="mv-choice" style="font-size:.8125rem">
<input type="checkbox" role="switch" class="mv-switch" id="st-skip">
Skip unavailable options
</label>
</div>
<mv-strips id="st-frame" name="print" label="Framed print options" measure="price" base-price="49" value="2-2-2-1"
rules='[
{ "if": "frame:gold", "forbid": "size:24x36", "reason": "Gold leaf frames are made up to 18 × 24 in." },
{ "if": "size:24x36", "require": "glazing:acrylic", "reason": "At 24 × 36 in, prints ship with acrylic: glass is too heavy to send safely." }
]'>
<div data-strip="frame" data-label="Frame">
<div data-value="oak" data-text="Natural oak"><span class="st-sw" style="--c:#c9a06c"></span><span class="st-opt">Natural oak<small>FSC-certified, clear matte finish</small></span></div>
<div data-value="walnut" data-text="Walnut" data-price="18"><span class="st-sw" style="--c:#5b3a29"></span><span class="st-opt">Walnut<small>Solid hardwood, oiled</small></span></div>
<div data-value="black" data-text="Matte black"><span class="st-sw" style="--c:#1f1f22"></span><span class="st-opt">Matte black<small>Slim aluminum profile</small></span></div>
<div data-value="gold" data-text="Gold leaf" data-price="45"><span class="st-sw" style="--c:linear-gradient(135deg,#a67c2e,#f0d78a 45%,#b18a3a)"></span><span class="st-opt">Gold leaf<small>Hand-gilded, made to order</small></span></div>
</div>
<div data-strip="mat" data-label="Mat">
<div data-value="none" data-text="No mat"><span class="st-sw" style="--c:repeating-linear-gradient(45deg,transparent 0 4px,rgb(128 128 128 / .25) 4px 5px)"></span><span class="st-opt">No mat<small>Print runs to the frame</small></span></div>
<div data-value="white" data-text="White mat" data-price="12"><span class="st-sw" style="--c:#f6f3ec"></span><span class="st-opt">White mat<small>Acid-free, 2 in border</small></span></div>
<div data-value="black" data-text="Black mat" data-price="12"><span class="st-sw" style="--c:#2a2a2d"></span><span class="st-opt">Black mat<small>Acid-free, 2 in border</small></span></div>
</div>
<div data-strip="size" data-label="Size">
<div data-value="8x10" data-text="8 × 10 in"><span class="st-opt">8 × 10 in<small>Desk or shelf</small></span></div>
<div data-value="12x16" data-text="12 × 16 in" data-price="30"><span class="st-opt">12 × 16 in<small>Hallway, small wall</small></span></div>
<div data-value="18x24" data-text="18 × 24 in" data-price="65"><span class="st-opt">18 × 24 in<small>Living room</small></span></div>
<div data-value="24x36" data-text="24 × 36 in" data-price="120"><span class="st-opt">24 × 36 in<small>Statement piece</small></span></div>
</div>
<div data-strip="glazing" data-label="Glazing">
<div data-value="glass" data-text="Standard glass"><span class="st-opt">Standard glass<small>Clear float glass</small></span></div>
<div data-value="acrylic" data-text="Anti-glare acrylic" data-price="22"><span class="st-opt">Anti-glare acrylic<small>Shatter-resistant, lighter</small></span></div>
<div data-value="museum" data-text="Museum glass" data-price="58"><span class="st-opt">Museum glass<small>99% UV protection, near-invisible</small></span></div>
</div>
</mv-strips>
<aside class="st-side" aria-label="Your print">
<div class="st-stage" aria-hidden="true">
<div class="st-frame" id="st-frame-view"><div class="st-mat"><div class="st-art"></div></div></div>
</div>
<div class="st-row">
<div style="display:grid">
<span class="st-k">Your print</span>
<span class="st-price" id="st-price">$0</span>
</div>
<span class="mv-badge" data-variant="secondary" data-shape="pill">Ships in 3–5 days</span>
</div>
<button type="submit" class="mv-button" id="st-submit">Add to cart</button>
<pre class="st-log" id="st-log" aria-live="polite">Submitted form data appears here.</pre>
</aside>
</form>
<script type="module">
await customElements.whenDefined("mv-strips");
const $ = (id) => document.getElementById(id);
/* Ad composer */
const ad = $("st-ad");
const status = $("st-ad-status");
const saved = $("st-ad-saved");
const renderAd = () => {
$("st-ad-text").textContent = ad.text;
const over = ad.total - Number(ad.max);
status.textContent = over > 0 ? `${over} over the limit` : "Within limit";
status.dataset.variant = over > 0 ? "danger" : "success";
};
ad.addEventListener("mv-change", renderAd);
renderAd();
$("st-ad-save").addEventListener("click", () => {
const code = ad.value;
if ([...saved.querySelectorAll("code")].some((c) => c.textContent === code)) return;
saved.querySelector(".st-empty")?.remove();
const li = document.createElement("li");
const b = document.createElement("button");
b.type = "button";
const c = document.createElement("code");
c.textContent = code;
const s = document.createElement("span");
s.textContent = ad.text;
b.append(c, s);
b.setAttribute("aria-label", `Restore variant ${code}: ${ad.text}`);
b.addEventListener("click", () => { ad.value = code; renderAd(); });
li.append(b);
saved.prepend(li);
while (saved.children.length > 4) saved.lastElementChild.remove();
$("st-ad-live").textContent = `Variant ${code} saved.`;
});
$("st-ad-copy").addEventListener("click", async () => {
try { await navigator.clipboard.writeText(ad.text); $("st-ad-live").textContent = "Ad text copied."; } catch { /* clipboard unavailable */ }
});
/* Frame configurator */
const fr = $("st-frame");
const view = $("st-frame-view");
const frames = { oak: "#c9a06c", walnut: "#5b3a29", black: "#1f1f22", gold: "linear-gradient(135deg,#a67c2e,#f0d78a 45%,#b18a3a)" };
const mats = { none: ["transparent", "0%"], white: ["#f6f3ec", "12%"], black: ["#2a2a2d", "12%"] };
const sizes = { "8x10": [5, 6.25], "12x16": [6.25, 8.3], "18x24": [7.5, 10], "24x36": [8, 12] };
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const renderFrame = () => {
const v = fr.values;
view.style.background = frames[v.frame];
const [mt, mp] = mats[v.mat];
view.style.setProperty("--mt", mt);
view.style.setProperty("--mp", mp);
const [w, h] = sizes[v.size];
view.style.setProperty("--w", `${w}rem`);
view.style.setProperty("--h", `${h}rem`);
view.style.setProperty("--gl", v.glazing === "museum" ? ".05" : v.glazing === "acrylic" ? ".1" : ".22");
$("st-price").textContent = money.format(fr.total);
const ok = !fr.conflicts.length;
$("st-submit").textContent = ok ? `Add to cart · ${money.format(fr.total)}` : "Resolve the conflict to continue";
$("st-submit").disabled = !ok;
};
fr.addEventListener("mv-change", renderFrame);
renderFrame();
$("st-skip").addEventListener("change", (e) => { fr.skipBlocked = e.target.checked; });
$("st-form").addEventListener("submit", (e) => {
e.preventDefault();
const data = [...new FormData(e.currentTarget)].map(([k, v]) => `${k}=${v}`).join("\n");
$("st-log").textContent = data;
});
</script>
</div>Cultural reference
Cent mille milliards de poèmes — Raymond Queneau (1961, book). Ten sonnets are printed on pages cut into fourteen strips, one per line, and turning any strip independently composes one of 10^14 always-valid poems. In the UI, a result is assembled from independent strips of interchangeable alternatives that flip one by one or all at once, with the number of combinations, a shareable code for the current one and rules that keep every composition valid.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
name | string | Form field name. The form receives name=<code> plus name[<strip key>]=<option value> for every strip (e.g. print=2-2-2-1, print[frame]=walnut…). | |
value | string (code) | Initial combination as 1-based option indexes per strip joined with “-” (“3-1-4-2”). Invalid or missing parts keep the strip’s default (its [data-selected] option, else the first enabled one). Form reset restores it. | |
label | string | Accessible name of the group (role="group"). | |
measure | "length" | "price" | Live sum shown in the bar: length counts the characters (graphemes) of the composed text; price adds base-price and every current option’s data-price, and shows each non-zero delta on its face (“+$18”). | |
max | number | Limit for the measure (characters, or a budget in currency units). A meter fills toward it (amber from 90%), and going over flags the bar and sets validity (tooLong for length, rangeOverflow for price). | |
base-price | number | 0 | Starting price for measure="price". |
currency / locale | string | USD / en-US | Intl formatting of prices and counts. |
unit | string | characters | Unit word shown after the length (“72 / 150 characters”). |
joiner | string | " " | Separator used to compose text (and to count its length) from the options’ texts. |
lockable | boolean | Shows a pin on every strip: pinned strips are kept by Shuffle and the bar also shows how many combinations remain with them. Pinned strips can still be flipped by hand. | |
skip-blocked | boolean | Flipping skips alternatives blocked by a rule instead of landing on them and explaining the conflict. | |
rules | JSON array | Compatibility rules, as JSON or through the rules property: { "if": "size:24x36", "forbid": "frame:gold" } or { "if": "size:24x36", "require": "glazing:acrylic|glazing:museum", "reason": "…" }. References are strip:value; lists use arrays or “|”. Forbid is symmetric; require makes every other option of the named strip incompatible with the if option. Without a reason, “Not available with Frame: Gold leaf.” is shown. | |
wheel | "hover" | "focus" | "off" | hover | Mouse wheel over a strip flips it. hover ignores wheels that are scrolling the page (a scroll happened in the last 350 ms), focus only reacts on the focused strip. |
show-bar | "true" | "false" | true | "false" hides the bar (combinations, code, measure, Shuffle) when the app renders its own. |
data-size | sm | lg | Denser strips (notification templates, sidebars) or larger ones (hero copy). | |
disabled | boolean | Disables every control (also from a disabled fieldset). | |
data-strip / data-label / data-locked | on each strip element | A child with data-strip="<key>" is a strip; data-label (or aria-label) names it; data-locked pins it initially. Its own children are the alternatives. | |
data-value / data-text / data-label / data-price / data-selected / data-disabled | on each option element | Option value (default: its 1-based index), the text used to compose and count (default: its text content, useful when the face holds rich markup), its spoken name, its price delta, the initially selected one, and an option that is never selectable. | |
data-code / data-conflict / data-invalid | set by the component | Reflect the current code, whether a rule is violated, and whether the value is invalid (conflict or over max), for styling. |
Properties
| Name | Type | Description |
|---|---|---|
value / code | string | Current combination code (“3-1-4-2”). Setting it flips instantly and does not emit mv-change. |
values | Record<string, string> | { stripKey: optionValue } of the current combination. Settable (partial objects allowed). |
text | string | Composed text (read-only): the current options’ texts joined with joiner. |
total | number | null | Current measure: character count or price (read-only). |
combinations | number | null | Number of valid combinations, rules applied, pins ignored (read-only). null when too large to count exactly under rules (the bar then shows “up to” the raw product). |
conflicts | Array<{ strip, option, reason, with: { strip, option } }> | Rules the current combination violates (read-only, empty when valid). |
strips | Array<{ key, label, locked?, index?, options: Array<{ value, label?, text?, price?, disabled? } | string> }> | Snapshot of the model; assign an array to build the strips from data instead of markup (text only, never HTML). |
rules | Array<{ if, forbid?, require?, reason? }> | Same as the attribute, as an array (takes precedence over the attribute once set). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (keys include combinations, valid, pool, shuffle, next, prev, pin, pinned, option, unavailable, conflict, conflictWith, hint, code, length, total, noValid, shuffled, tooLong, overBudget). English defaults. |
Methods
| Name | Description |
|---|---|
flip(strip, delta = 1) | Flips a strip (key, index or element) by ±n usable options, animated. Emits mv-flip then mv-change. Returns true if it changed. |
select(strip, option) | Flips a strip to an option value (or a 0-based index when a number). Returns true if it changed. |
shuffle({ all = false }) | Random valid combination for every unpinned strip (all: pinned ones too), preferring a new option in each and, when max is set, a combination that fits it; emits the cancelable mv-shuffle, then flips in a cascade and announces the result. Returns the new code, or null. |
lock(strip, locked = true) | Pins or unpins a strip (emits mv-lock). |
announce() | Reads the whole composed result once in the polite live region (same as Enter on a strip). |
refresh() | Re-reads strips and options after their markup changed. |
Events
| Name | Description |
|---|---|
mv-flip | Cancelable, before a single strip changes. detail: { strip, from, to, fromIndex, toIndex, source: "arrow" | "key" | "drag" | "tap" | "wheel" | "api" }. preventDefault() keeps the current option (e.g. a premium option the user hasn’t unlocked). |
mv-shuffle | Cancelable, before a shuffle is applied. detail: { value (proposed code), values, previous, all }. |
mv-change | The combination changed through the UI or a method. detail: { value, values, text, total, conflicts, strip (key or null for a shuffle), source }. |
mv-lock | A strip was pinned or unpinned. detail: { strip, locked }. |
Content structure
| Name | Description |
|---|---|
(strips) | Children with data-strip, each containing its alternatives (any markup: text, swatches, images). They are moved into the sheet; the option elements themselves become the faces. |
bar | Children with slot="bar" are placed in the bar before the Shuffle button (e.g. “Use this copy”). |
CSS classes
| Name | Description |
|---|---|
mv-strips-sheet / mv-strips-strip | The stack of strips (the gap between strips is the cut, --mv-strips-cut) and each strip row (data-locked, data-conflict, data-flipping). |
mv-strips-head / -label / -counter | Strip name and “2/6” position. |
mv-strips-window / mv-strips-face | The spinbutton slot and its alternatives, all stacked in one grid cell (data-current, data-leaving during a flip, data-blocked, data-disabled). -delta is the generated price delta. |
mv-strips-tools / -arrows / -tool / -pin | Next / previous arrows and the pin toggle (aria-pressed). |
mv-strips-note | Conflict explanation under a strip whose current option breaks a rule. |
mv-strips-bar / -stats / -total / -pool / -code / -measure / -meter / -shuffle | Bar parts: combination count, count with pins, code chip, measure with its meter (data-near, data-over), Shuffle button (.mv-button). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-strips-paper | var(--mv-surface-raised) | Face background. |
--mv-strips-ink | var(--mv-fg) | Face text color. |
--mv-strips-accent | var(--mv-accent) | Pinned strips and the pool dot. |
--mv-strips-conflict | var(--mv-warning) | Blocked faces (hatching, dashed edge) and the conflict note. |
--mv-strips-cut | 3px | Gap between strips. |
--mv-strips-label-width | 7.5rem | Width of the label column (the label moves above the strip in containers under 34rem). |
--mv-strips-shade | 0.86 | Brightness of the face beneath a flipping strip (its shadow). |
Accessibility
The component is a labelled group; each strip’s slot is a focusable role="spinbutton" named by the strip label, with aria-valuenow / aria-valuemax and aria-valuetext “Option 2 of 6: Stop losing deals to slow follow-ups.” (plus “Unavailable. <reason>” and aria-invalid when a rule blocks the current option). Up / Down flip it (PageUp / PageDown by three, Home / End to the first or last usable option), Tab moves to the next strip, and a visually hidden hint linked with aria-describedby says so. Each flip is conveyed by the spinbutton value alone: the whole composition is never read on every flip, only on demand (Enter on any strip, or announce()) and once after Shuffle, in a polite status region. Pins are real toggle buttons (aria-pressed, “Pin Hook”), in the tab order right after their strip; a pinned strip is also described as “Pinned: kept when shuffling.” The arrow buttons are pointer affordances outside the tab order (the keys do the same) but remain labelled (“Next hook”) for screen-reader virtual cursors. The conflict note is linked to its strip with aria-describedby, conflicts and an exceeded max set form validity (customError, tooLong, rangeOverflow) anchored on the strip, and colors always come with words and patterns (hatching, dashed edge, warning icon). Faces are aria-hidden: their content is spoken through aria-valuetext (data-label gives rich faces a clean name). Reduced motion (OS or data-motion="reduce"): flips become a short crossfade and drag feedback is off. Forced colors: faces and meters use system colors, focus and pins use Highlight. Wheel input ignores scrolls that are moving the page, and touch keeps vertical page scrolling (strips follow horizontal swipes on touch).