익스클루시브
Strips <mv-strips>
조합형 작성기입니다. 결과물(광고 문구, 제품 구성, 알림, 메뉴)이 한 페이지를 여러 조각으로 자른 듯 쌓인 독립적인 띠들로 조립되며, 각 띠는 교체 가능한 대안들이 페이지를 들어 넘기는 실감 나는 애니메이션으로 따로 넘어가는 슬롯입니다(화살표, 세로 드래그 또는 가로 스와이프, 탭, 포커스된 띠에서는 마우스 휠과 Up/Down/Home/End).
문화적 레퍼런스
Cent mille milliards de poèmes, 레몽 크노(1961, 책). 소네트 열 편이 한 줄에 하나씩 열네 개의 띠로 잘린 페이지에 인쇄되어 있어, 어느 띠든 독립적으로 넘기면 언제나 유효한 10^14편의 시 가운데 하나가 만들어집니다. UI에서는 서로 바꿔 끼울 수 있는 대안으로 이루어진 독립 띠들로 결과물을 조립합니다. 띠는 하나씩 또는 한꺼번에 넘어가며, 조합의 수, 현재 조합을 공유할 수 있는 코드, 모든 조합을 유효하게 유지하는 규칙이 함께 제공됩니다.
작동 방식
조합형 작성기: 결과물(광고 문구, 제품 구성, 알림, 메뉴)을 한 페이지를 여러 조각으로 자른 듯 쌓인 독립된 띠들로 조립합니다. 각 띠는 하나의 슬롯이며, 서로 바꿔 끼울 수 있는 대안들이 실제로 페이지를 들어 올리는 듯한 애니메이션과 함께 개별적으로 넘어갑니다(화살표, 세로 드래그 또는 가로 스와이프, 탭, 그리고 포커스된 띠에서는 마우스 휠과 Up/Down/Home/End). Shuffle은 고정되지 않은 모든 띠를 시차를 둔 연쇄 동작으로 넘기며 언제나 유효한 조합에만 멈춥니다. 핀은 마음에 드는 부분을 그대로 유지합니다. 바에는 실시간 조합 수(규칙이 있어도 정확하며, 현재 핀 상태에서 남은 수도 표시)와 현재 조합의 공유 코드(“3-1-4-2”, 즉 value)가 표시됩니다. 선택적인 쌍별 규칙(forbid / require)은 호환되지 않는 대안을 이유와 함께 회색으로 처리하고, 방금 넘긴 띠에서 충돌을 자세히 설명하며(상대 띠는 그 띠를 다시 가리킴), 폼 유효성을 설정합니다. 선택적인 measure는 넘길 때마다 글자 수를 최댓값과 비교해 합산하거나 옵션별 증감으로 가격을 합산합니다. 폼 연동: 코드와 함께 모든 띠의 name[strip]=value를 제출하며, 취소 가능한 mv-flip과 mv-shuffle로 앱이 거부할 수 있습니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-strips>) |
| 상태 | 안정 |
| 함께 설치되는 항목 | 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
설치
node scripts/add.mjs strips --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["strips"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/form.js, core/motion.js, core/pointer.js, components/strips/strips.js, components/strips/strips.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<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) } }
/* Swatches and the framed preview show real product colors: keep them in forced colors. */
@media (forced-colors: active) { #st-demo :is(.st-sw, .st-frame) { forced-color-adjust:none; outline:1px solid CanvasText } }
</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.example.com</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>API
Attributes
| Name | 유형 | 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 | "focus" | "hover" | "off" | focus | When the mouse wheel flips a strip. focus (default): only the focused strip reacts, so the wheel always scrolls the page otherwise. hover: a strip also takes the wheel once the pointer has rested on it for half a second and the page has not scrolled in the last 350 ms. off: the wheel never flips. |
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 | 유형 | 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. By default the wheel only flips the focused strip, so it never traps page scrolling, and touch keeps vertical page scrolling (strips follow horizontal swipes on touch).