익스클루시브
Narrow Down <mv-narrow-down>
고정된 설문 대신 가장 많은 정보를 얻을 수 있는 질문을 다음에 던지는 가이드형 상품 찾기 도구입니다.
문화적 레퍼런스
아키네이터, Elokence (2007, 게임). 웹 지니가 예 / 아니요 / 모르겠음 / 아마도로 답하는 질문을 한 번에 하나씩 던져 사용자가 떠올린 캐릭터를 맞힙니다. 매번 아직 가능성이 있다고 보는 후보들을 가장 잘 나누는 질문을 골라 하나만 남을 때까지 계속합니다. 이 컴포넌트는 이를 제품 찾기 도구로 바꿉니다. 다음 질문은 언제나 남은 카탈로그를 가장 잘 나누는 질문이고, “don't know”와 누락된 데이터는 후보를 탈락시키지 않으며, “probably”는 순위만 바꾸고, 줄어드는 후보 풀과 “Why this question?”이 추론 과정을 드러냅니다.
작동 방식
고정된 설문 대신 가장 정보량이 많은 질문을 다음에 던지는 안내형 제품 찾기 도구입니다. 속성이 있는 후보 카탈로그(텐트, 노트북, 플랜, 템플릿, 통합, 도움말 문서)와 그 속성에 연결된 질문 목록을 프로퍼티 또는 JSON 스크립트로 전달합니다. 매 단계마다 아직 답하지 않은 각 질문이 남은 후보를 얼마나 고르게 나누는지 점수를 매기고(엔트로피 × 데이터 커버리지, 클라이언트 측에서 계산), 가장 좋은 질문을 실제 라디오 타일로 된 큰 카드로 보여 줍니다: Yes / No / Don't know / Doesn't matter, Probably, 또는 범위가 있는 작은 선택지 세트("Under 3 lb", "$200-$400"). 옆의 후보 풀은 실시간으로 줄어듭니다. 제외된 카드는 사라지고 남은 카드는 순위대로 모이며, "16 → 5 matches" 개수가 표시됩니다. “Don't know”는 어떤 것도 제외하지 않고, 누락된 데이터는 후보를 탈락시키지 않으며, 소프트 질문과 “Probably” 답변은 제외하는 대신 순위를 다시 매기고, 아무것도 남기지 않을 답변은 비활성화됩니다. “Why this question?”은 각 답변이 몇 개를 남길지 보여 주고, 칩으로 된 기록에서 다른 답변은 유지한 채 이전 답변을 바꿀 수 있으며, Back은 마지막 답변을 취소하고, “See the 5 matches”로 일찍 끝낼 수 있습니다. 결과 화면은 남은 후보를 그 이유(“Matches: storm-rated fly, under 3 lb”, “Misses: stand-up headroom”) 및 적합도 점수와 함께 순위대로 보여 줍니다. 조건부 질문(requires), “No”가 “필요 없음”만을 뜻하는 단방향 질문, 답변별 개수, 질문 수 제한, 복원 가능한 답변이 기본 제공되며, mv-answer는 취소할 수 있습니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-narrow-down>) |
| 상태 | 안정 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, form, product-finder, recommendation, quiz, questionnaire, guided-selling, wizard, decision-tree, entropy, filter, shortlist, comparison, e-commerce, explainable |
When to use
- Shoppers face a catalog of 10 to a few hundred products they can't compare on specs alone and need to be guided to a shortlist
- A plan, template, integration or support article picker should ask only the questions that matter for the options still in play
- A fixed multi-step quiz asks irrelevant questions and should adapt to each answer and stop as soon as few options remain
- The recommendation must stay transparent: why a question is asked, what each answer rules out and why each result fits
Avoid when
- Users already know the specs they want and should filter a large listing freely, in any order → use Facets instead
- Only two to six options are on screen and the user needs help picking one of them → use Lifelines instead
- The steps are a fixed process (checkout, onboarding) rather than questions that narrow a catalog → use Stepper instead
설치
node scripts/add.mjs narrow-down --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["narrow-down"], 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/motion.js, components/narrow-down/narrow-down.js, components/narrow-down/narrow-down.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="nd-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#nd-demo .nd-top { display:flex; align-items:flex-end; justify-content:space-between; gap:1rem 1.5rem; flex-wrap:wrap; margin:0 0 1.25rem }
#nd-demo .nd-top h3 { margin:0; font-size:1.25rem; letter-spacing:-.015em }
#nd-demo .nd-top p { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.875rem }
#nd-demo .nd-controls { display:flex; align-items:center; gap:1rem; flex-wrap:wrap }
#nd-demo .nd-toggle { display:inline-flex; align-items:center; gap:.5rem; font-size:.8125rem; color:var(--mv-fg-muted); cursor:pointer }
#nd-demo .nd-log { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:1rem 0 0; min-height:1.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#nd-demo .nd-log code { font-family:var(--mv-font-mono); font-size:.6875rem; color:var(--mv-fg) }
</style>
<div class="nd-top">
<div>
<h3 id="nd-title">Find your tent</h3>
<p id="nd-sub">A few questions, only the ones that matter for what's still in the running.</p>
</div>
<div class="nd-controls">
<mv-segmented id="nd-catalog" name="catalog" value="tents" aria-label="Catalog">
<button value="tents">Tents</button>
<button value="laptops">Laptops</button>
</mv-segmented>
<label class="nd-toggle"><input type="checkbox" role="switch" class="mv-switch" id="nd-counts"> Show counts</label>
</div>
</div>
<mv-narrow-down id="nd-finder" label="Tent finder" result-at="3">
<script type="application/json">
{
"answers": { "weight": "light5" },
"questions": [
{ "id": "people", "text": "How many people will sleep in it?", "label": "Sleeps", "attr": "capacity",
"options": [
{ "value": "1", "label": "Just me", "max": 2, "reason": "solo" },
{ "value": "2", "label": "Two", "min": 2, "max": 3, "reason": "sleeps two" },
{ "value": "3-4", "label": "Three or four", "min": 3, "max": 5, "reason": "sleeps 3-4" },
{ "value": "5+", "label": "Five or more", "min": 5, "reason": "family size" }
], "answers": ["any"] },
{ "id": "weight", "text": "How much weight are you willing to carry?", "label": "Weight", "attr": "weight",
"options": [
{ "value": "light3", "label": "As little as possible", "hint": "Under 3 lb, for long trails", "max": 3, "reason": "under 3 lb" },
{ "value": "light5", "label": "A reasonable load", "hint": "Under 5 lb, for weekend hikes", "max": 5, "reason": "under 5 lb" }
], "answers": ["any"] },
{ "id": "snow", "text": "Will you camp in snow or high winds?", "label": "Snow & wind", "attr": "fourSeason",
"hint": "Four-season tents shed snow and wind but run warm in summer.", "reason": "four-season", "reasonNo": "3-season ventilation" },
{ "id": "rain", "text": "Do you expect long, heavy rain?", "label": "Heavy rain", "attr": "stormProof", "oneSided": true,
"reason": "storm-rated fly", "answers": ["yes", "no", "unknown"] },
{ "id": "ground", "text": "Will you pitch on rock, sand or wooden platforms?", "label": "Hard ground", "attr": "freestanding", "oneSided": true,
"hint": "Freestanding tents stand up without stakes.", "reason": "freestanding", "answers": ["yes", "no", "unknown"] },
{ "id": "budget", "text": "What's your budget?", "label": "Budget", "attr": "price",
"options": [
{ "value": "200", "label": "Under $200", "max": 200, "reason": "under $200" },
{ "value": "400", "label": "$200 to $400", "min": 200, "max": 400, "reason": "$200-$400" },
{ "value": "400+", "label": "Over $400", "min": 400, "reason": "premium build" }
], "answers": ["any"] },
{ "id": "headroom", "text": "Would you like to stand up inside?", "label": "Headroom", "attr": "peak", "min": 72,
"soft": true, "oneSided": true, "reason": "stand-up headroom", "answers": ["yes", "no", "any"] },
{ "id": "doors", "text": "Would a door on each side help?", "label": "Two doors", "attr": "doors", "min": 2,
"soft": true, "oneSided": true, "reason": "two doors", "requires": { "people": ["2", "3-4", "5+"] }, "answers": ["yes", "no", "any"] }
],
"items": [
{ "id": "ridgeline-1", "name": "Ridgeline UL 1", "caption": "1P · 2.1 lb · 3-season", "price": 329, "href": "#ridgeline-1", "attrs": { "capacity": 1, "weight": 2.1, "fourSeason": false, "stormProof": false, "freestanding": false, "peak": 40, "doors": 1 } },
{ "id": "ridgeline-2", "name": "Ridgeline UL 2", "caption": "2P · 2.9 lb · 3-season", "price": 399, "href": "#ridgeline-2", "attrs": { "capacity": 2, "weight": 2.9, "fourSeason": false, "stormProof": false, "freestanding": false, "peak": 42, "doors": 2 } },
{ "id": "summit-pass-2", "name": "Summit Pass 2", "caption": "2P · 3.6 lb · 3-season", "price": 449, "href": "#summit-pass-2", "attrs": { "capacity": 2, "weight": 3.6, "fourSeason": false, "stormProof": true, "freestanding": true, "peak": 43, "doors": 2 } },
{ "id": "trailhead-2", "name": "Trailhead 2", "caption": "2P · 4.8 lb · 3-season", "price": 179, "href": "#trailhead-2", "attrs": { "capacity": 2, "weight": 4.8, "fourSeason": false, "stormProof": false, "freestanding": true, "peak": 44, "doors": 2 } },
{ "id": "trailhead-3", "name": "Trailhead 3", "caption": "3P · 5.9 lb · 3-season", "price": 229, "href": "#trailhead-3", "attrs": { "capacity": 3, "weight": 5.9, "fourSeason": false, "stormProof": false, "freestanding": true, "peak": 46, "doors": 2 } },
{ "id": "basecamp-4", "name": "Basecamp 4", "caption": "4P · 9.5 lb · 3-season", "price": 349, "href": "#basecamp-4", "attrs": { "capacity": 4, "weight": 9.5, "fourSeason": false, "stormProof": true, "freestanding": true, "peak": 58, "doors": 2 } },
{ "id": "meadow-6", "name": "Meadow Dome 6", "caption": "6P · 14.2 lb · 3-season", "price": 289, "href": "#meadow-6", "attrs": { "capacity": 6, "weight": 14.2, "fourSeason": false, "stormProof": false, "freestanding": true, "peak": 76, "doors": 1 } },
{ "id": "cabin-6", "name": "Cabin Plus 6", "caption": "6P · 18.7 lb · 3-season", "price": 479, "href": "#cabin-6", "attrs": { "capacity": 6, "weight": 18.7, "fourSeason": false, "stormProof": true, "freestanding": true, "peak": 80, "doors": 2 } },
{ "id": "arete-2", "name": "Arête 2", "caption": "2P · 5.4 lb · 4-season", "price": 699, "href": "#arete-2", "attrs": { "capacity": 2, "weight": 5.4, "fourSeason": true, "stormProof": true, "freestanding": true, "peak": 42, "doors": 2 } },
{ "id": "arete-3", "name": "Arête 3", "caption": "3P · 6.8 lb · 4-season", "price": 799, "href": "#arete-3", "attrs": { "capacity": 3, "weight": 6.8, "fourSeason": true, "stormProof": true, "freestanding": true, "peak": 44, "doors": 2 } },
{ "id": "solo-shelter", "name": "Solo Shelter", "caption": "1P · 1.4 lb · trekking-pole", "price": 249, "href": "#solo-shelter", "attrs": { "capacity": 1, "weight": 1.4, "fourSeason": false, "stormProof": false, "freestanding": false, "peak": 48, "doors": 1 } },
{ "id": "bivy-pro", "name": "Alpine Bivy", "caption": "1P · 1.9 lb · 4-season", "price": 319, "href": "#bivy-pro", "attrs": { "capacity": 1, "weight": 1.9, "fourSeason": true, "stormProof": true, "freestanding": false, "peak": 22, "doors": 1 } },
{ "id": "lakeside-4", "name": "Lakeside 4", "caption": "4P · 11 lb · 3-season", "price": 219, "href": "#lakeside-4", "attrs": { "capacity": 4, "weight": 11, "fourSeason": false, "stormProof": false, "freestanding": true, "peak": 74, "doors": 2 } },
{ "id": "coastline-3", "name": "Coastline 3", "caption": "3P · 4.2 lb · 3-season", "price": 529, "href": "#coastline-3", "attrs": { "capacity": 3, "weight": 4.2, "fourSeason": false, "stormProof": true, "freestanding": true, "peak": 45, "doors": 2 } },
{ "id": "pocket-2", "name": "Pocket 2", "caption": "2P · 2.4 lb · 3-season", "price": 159, "href": "#pocket-2", "attrs": { "capacity": 2, "weight": 2.4, "fourSeason": false, "stormProof": false, "freestanding": false, "peak": 41, "doors": 1 } },
{ "id": "family-hub-8", "name": "Family Hub 8", "caption": "8P · 22 lb · 3-season", "price": 649, "href": "#family-hub-8", "attrs": { "capacity": 8, "weight": 22, "fourSeason": false, "stormProof": true, "freestanding": true, "peak": 82, "doors": 2 } }
]
}
</script>
</mv-narrow-down>
<p class="nd-log" id="nd-log" aria-hidden="true"><span class="mv-badge" data-variant="secondary">event</span><span>Answer a question: events show here</span></p>
<script type="module">
const finder = document.getElementById("nd-finder");
await customElements.whenDefined("mv-narrow-down");
const $ = (id) => document.getElementById(id);
const catalogs = {
tents: { title: "Find your tent", sub: "A few questions, only the ones that matter for what's still in the running.", label: "Tent finder", items: finder.items, questions: finder.questions },
laptops: {
title: "Find your laptop",
sub: "Twelve models, narrowed down by how you'll actually use it.",
label: "Laptop finder",
questions: [
{ id: "use", text: "What will you mostly use it for?", label: "Use", attr: "uses", options: [
{ value: "office", label: "Email, docs and browsing", reason: "great for everyday work" },
{ value: "creative", label: "Photo and video editing", reason: "color-accurate screen" },
{ value: "code", label: "Software development", reason: "fast builds" },
{ value: "gaming", label: "Gaming", reason: "dedicated GPU" },
], answers: ["any"] },
{ id: "os", text: "Which operating system do you want?", label: "System", attr: "os", options: [
{ value: "macos", label: "macOS" }, { value: "windows", label: "Windows" }, { value: "chromeos", label: "ChromeOS" },
], answers: ["any"] },
{ id: "carry", text: "Will you carry it every day?", label: "Daily carry", attr: "weight", max: 3.5, oneSided: true, reason: "under 3.5 lb", answers: ["yes", "no", "unknown"] },
{ id: "budget", text: "What's your budget?", label: "Budget", attr: "price", options: [
{ value: "1000", label: "Under $1,000", max: 1000, reason: "under $1,000" },
{ value: "1800", label: "$1,000 to $1,800", min: 1000, max: 1800, reason: "$1,000-$1,800" },
{ value: "1800+", label: "Over $1,800", min: 1800, reason: "top of the line" },
], answers: ["any"] },
{ id: "screen", text: "Do you prefer a large screen?", label: "Large screen", attr: "screen", min: 15, soft: true, reason: "15-inch or larger", reasonNo: "compact screen", answers: ["yes", "no", "any"] },
{ id: "battery", text: "Do you need all-day battery life?", label: "Battery", attr: "battery", min: 14, soft: true, oneSided: true, reason: "14+ hours battery", answers: ["yes", "probably", "no"] },
],
items: [
{ id: "aero-13", name: "Aero 13", caption: '13.6" · 2.7 lb · 18 h', price: 1099, href: "#aero-13", attrs: { uses: ["office", "code"], os: "macos", weight: 2.7, screen: 13.6, battery: 18 } },
{ id: "aero-15", name: "Aero 15", caption: '15.3" · 3.3 lb · 18 h', price: 1299, href: "#aero-15", attrs: { uses: ["office", "code", "creative"], os: "macos", weight: 3.3, screen: 15.3, battery: 18 } },
{ id: "studio-14", name: "Studio Pro 14", caption: '14.2" · 3.4 lb · 17 h', price: 1999, href: "#studio-14", attrs: { uses: ["creative", "code"], os: "macos", weight: 3.4, screen: 14.2, battery: 17 } },
{ id: "studio-16", name: "Studio Pro 16", caption: '16.2" · 4.7 lb · 21 h', price: 2499, href: "#studio-16", attrs: { uses: ["creative", "code"], os: "macos", weight: 4.7, screen: 16.2, battery: 21 } },
{ id: "vector-14", name: "Vector 14", caption: '14" · 2.9 lb · 12 h', price: 1349, href: "#vector-14", attrs: { uses: ["office", "code"], os: "windows", weight: 2.9, screen: 14, battery: 12 } },
{ id: "vector-16", name: "Vector 16 Creator", caption: '16" · 4.1 lb · 9 h', price: 1899, href: "#vector-16", attrs: { uses: ["creative", "gaming"], os: "windows", weight: 4.1, screen: 16, battery: 9 } },
{ id: "forge-15", name: "Forge 15", caption: '15.6" · 5.1 lb · 6 h', price: 1199, href: "#forge-15", attrs: { uses: ["gaming"], os: "windows", weight: 5.1, screen: 15.6, battery: 6 } },
{ id: "forge-17", name: "Forge 17 Ultra", caption: '17.3" · 6.4 lb · 5 h', price: 2299, href: "#forge-17", attrs: { uses: ["gaming", "creative"], os: "windows", weight: 6.4, screen: 17.3, battery: 5 } },
{ id: "pilot-14", name: "Pilot 14 Business", caption: '14" · 2.5 lb · 15 h', price: 1549, href: "#pilot-14", attrs: { uses: ["office", "code"], os: "windows", weight: 2.5, screen: 14, battery: 15 } },
{ id: "everyday-15", name: "Everyday 15", caption: '15.6" · 3.8 lb · 10 h', price: 649, href: "#everyday-15", attrs: { uses: ["office"], os: "windows", weight: 3.8, screen: 15.6, battery: 10 } },
{ id: "slate-13", name: "Slate 13", caption: '13.3" · 2.4 lb · 12 h', price: 429, href: "#slate-13", attrs: { uses: ["office"], os: "chromeos", weight: 2.4, screen: 13.3, battery: 12 } },
{ id: "canvas-14", name: "Canvas 14 OLED", caption: '14" · 3.1 lb · 13 h', price: 999, href: "#canvas-14", attrs: { uses: ["creative", "office"], os: "windows", weight: 3.1, screen: 14, battery: 13 } },
],
},
};
$("nd-catalog").addEventListener("mv-change", (e) => {
const c = catalogs[e.detail.value];
if (!c) return;
$("nd-title").textContent = c.title;
$("nd-sub").textContent = c.sub;
finder.label = c.label;
finder.items = c.items;
finder.questions = c.questions;
});
$("nd-counts").addEventListener("change", (e) => { finder.counts = e.target.checked; });
const log = $("nd-log");
const show = (name, text, variant = "secondary") => {
const badge = Object.assign(document.createElement("span"), { className: "mv-badge", textContent: name });
badge.dataset.variant = variant;
const code = Object.assign(document.createElement("code"), { textContent: text });
log.replaceChildren(badge, code);
};
finder.addEventListener("mv-answer", (e) => {
const d = e.detail;
show("mv-answer", `${d.question.label ?? d.id}: ${d.label} · ${d.before} → ${d.after}`, "info");
});
finder.addEventListener("mv-result", (e) => {
const d = e.detail;
show("mv-result", `${d.reason} · ${d.items.slice(0, 3).map((i) => i.name).join(", ")}${d.items.length > 3 ? "…" : ""}`, "success");
});
finder.addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
result-at | number | 3 | Show the result view as soon as this many candidates (or fewer) remain. It also shows when no remaining question can split the candidates any further. |
max-questions | number | Optional cap on the number of answers before the result view shows (reason "limit"). | |
counts | boolean | false | Show on every answer tile how many candidates it would keep ("5 left"), or how many fit for soft questions ("4 fit"). Answers that would leave nothing always show “None left” and are disabled. |
locale | string | en-US | Locale used to format numeric prices. |
currency | string | USD | Currency used to format numeric prices. |
heading-level | number (2..6) | 3 | Level of the result view heading, to fit your page outline. |
label | string | Guided finder | Accessible name of the region (e.g. "Tent finder"). |
data-view | "ask" | "result" | Set by the component on itself: style the page around the current view. |
Properties
| Name | 유형 | Description |
|---|---|---|
items | Array<{ id, name, caption?, price?, href?, cta?, attrs }> | The candidate catalog. Attributes are read from item.attrs[key], then item[key]; a value can be a boolean, string, number or an array (multi-valued, e.g. uses: ["coding", "gaming"]). A missing attribute means “no data”: the item is kept whatever the answer. price may be a number (formatted with locale and currency) or a string. Can also come from a child <script type="application/json">{ "items": [...], "questions": [...], "answers"?: {...} }</script>. |
questions | Array<Question> | Question bank. Yes/no question: { id, text, label?, hint?, attr, is? | in? | min? / max? | match?(item), answers?, labels?, soft?, oneSided?, weight?, requires?, reason?, reasonNo? }. Yes means the test passes (attr truthy, equal to is, in the list, or min ≤ value < max). Option question: { id, text, label?, attr, options: [{ value, label, hint?, is? | in? | min? / max? | match?, reason? }], answers?: ["unknown", "any"] }. An option without a test matches attr === value. answers picks the buttons among "yes", "no", "probably", "probably-not", "unknown" (Don't know) and "any" (Doesn't matter). soft: re-ranks, never rules out. oneSided: “No” means “not needed” and rules nothing out. requires: { questionId: value | [values] } asks it only after those answers. weight (default 1) biases the choice. reason / reasonNo / option.reason label the result reasons (default: the question label or option label). |
answers | { [questionId]: value } | Answers so far, in the order given (the trail). Set it to restore a session (e.g. from the URL); invalid entries and answers that would empty the pool are skipped. |
remaining | Array<item> | Read-only: the candidates still in play, best fit first. |
current | Question | null | Read-only: the question on screen (null on the result view). |
view | "ask" | "result" | Read-only: the current view. |
renderItem | (item, { context: "pool" | "result" }) => Node | Custom content for a candidate card (thumbnail, rating…). Return DOM nodes, never an HTML string. |
strings | object | Partial override of every visible text and announcement (English defaults), with {n}-style placeholders. |
resultAt / maxQuestions / counts / locale / currency / headingLevel / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
answer(value) | Answers the current question ("yes", "no", "unknown", "any", "probably", "probably-not" or an option value). Returns false if the value is invalid, would leave nothing, or mv-answer was canceled. |
back() | Undoes the last answer and shows that question again with the answer pre-selected (or cancels an edit). |
edit(questionId) | Re-opens an earlier question to change its answer; the other answers are kept and the pool is recomputed. Same as clicking its chip in the trail. |
showResults() | Stops asking and shows the ranked remaining candidates (reason "early"). |
keepNarrowing() | From the result view, keeps asking informative questions down to a single candidate. Returns false when no question can split them any further. |
reset() | Clears every answer and starts over. |
Events
| Name | Description |
|---|---|
mv-question | A question is shown. detail: { question, id, step, remaining, score, outcomes: [{ answer, label, keeps }] }. Handy for analytics (which questions people can't answer). |
mv-answer | Cancelable, before an answer is applied. detail: { question, id, answer, label, before, after (counts), remaining (ranked items after the answer), answers, editing }. preventDefault() refuses the answer (e.g. to show your own validation). |
mv-result | The result view is shown. detail: { reason: "narrowed" | "exhausted" | "early" | "limit", items (ranked), picks: [{ item, score, matches, misses, considered, fits }], answers }. |
Content structure
| Name | Description |
|---|---|
(json) | Optional <script type="application/json"> child with { items, questions, answers? }, read once at mount when the properties are not set. |
result | An element with slot="result" is moved into the result view footer (e.g. “Compare these” or “Talk to an expert”). |
CSS classes
| Name | Description |
|---|---|
mv-narrow-down-bar / -trail / -chip | Top bar: the trail of answers (each chip re-opens its question; aria-current while editing), Back and Start over. |
mv-narrow-down-card / -question / -answer / -radio / -key / -why | Question card: legend, radio tiles (data-kind="primary | skip", data-disabled, data-chosen during the confirmation), number-key badges and the “Why this question?” disclosure. |
mv-narrow-down-pool / -list / -item / -count | The shrinking pool: a real list; ruled-out cards get data-out, then hidden. The count shows data-changed when it moved. |
mv-narrow-down-result / -pick / -reasons / -fit | Result view: ranked picks (data-rank), reasons (data-kind="matches | misses") and fit bar. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-narrow-down-accent | var(--mv-accent) | Selected answer, progress meter, best pick. |
--mv-narrow-down-match | var(--mv-success) | Check marks and fit bars in the results. |
--mv-narrow-down-pool-width | 19rem | Width of the pool column when the component is at least 46rem wide (container query); below that, the pool stacks under the question. |
--mv-narrow-down-sticky-top | var(--mv-space-4) | Sticky offset of the pool column (e.g. under a fixed header). |
Accessibility
The component is a region named by label. Each question is a native <fieldset> whose <legend> is the question, with real radio inputs covering each tile, so screen readers announce the group, the option, its position and its count. A pointer click, a tap, Space or a screen-reader activation answers at once; arrow keys only move the selection (the browser's radio behavior) and Enter confirms, so nobody answers by accident while exploring; number keys 1-9 answer directly (aria-keyshortcuts, and a hint referenced by aria-describedby appears on keyboard focus). After an answer, focus moves to the next question's first option (or the result heading, which is focusable with tabindex=-1), and a polite live region says what happened: “Yes. 5 of 16 left.” Disabled answers (they would leave nothing) are native disabled radios labelled “None left”. The trail is an ordered list of buttons whose accessible name reads “Change your answer to “…” (currently …)”, “Why this question?” is a native <details>, the pool is a real <ul> labelled with its count where ruled-out cards are removed with hidden (the visual count is aria-hidden to avoid double reading), and results are an ordered list with their reasons in text. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no confirmation delay, no slide between questions, no fade or reflow animation in the pool. Forced colors: selection, focus and meters use Highlight. Colors come from tokens and are checked in light and dark themes.