Exclusivebeta
Deliberation — <mv-deliberation>
Round-based group decisions with hidden votes and visible convergence: estimation (planning poker), go/no-go calls, hiring debriefs, design critiques, prioritization or retro action votes. Each participant votes privately on a scale (Fibonacci, t-shirt, go/no-go, hire, confidence, a numeric range or your own list, with an optional “?” and Abstain); the table shows who has voted with face-down cards, never what. Reveal flips every card at once, then the round is analyzed: distribution with each voter as a chip, median, range, agreement, the consensus candidate, and the voters far from the group named in plain text (“Aiko and Mateo voted far from the group (13 pts and 1 pt): hear them first”), or a two-camp split when there is one. The group discusses and votes again; a convergence chart plots every round on the same scale so the range can be seen narrowing, and once the rule is met (unanimous, supermajority, majority, or a facilitator call, with an optional round limit) the facilitator records the decision with its rationale. Anonymous rounds lay the cards out by value, detached from names, and stay anonymous afterwards; an optional timer reveals when it runs out. The app keeps the data: it syncs votes over its own backend with vote(), markVoted() (the server keeps values secret) and reveal(votes), and every step goes through cancelable mv-vote, mv-reveal (with waitUntil), mv-round and mv-decision events.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-deliberation>) |
| Status | beta |
| Also installs | button, textarea |
| Keywords | exclusive, culture, voting, vote, consensus, planning-poker, estimation, story-points, go-no-go, decision, decision-log, hiring, debrief, retro, team, collaboration, facilitation, outliers, convergence, chart, realtime, anonymous |
When to use
- A team estimates backlog items together and needs independent votes before anyone anchors on the loudest number
- A release, launch or incident review needs an explicit go/no-go where every dissent is surfaced and heard
- A hiring debrief or design critique should collect private ratings first, then discuss the outliers and re-vote
- A decision must be logged with its rule, tally and rationale once a group actually reaches consensus
Avoid when
- People each contribute something different toward one shared goal rather than voting on one question → use Stone Soup instead
- A single person rates a product or answer as form input → use Rating instead
- A one-shot poll or survey with many questions and no discussion rounds: a form or poll tool is simpler
Install
node scripts/add.mjs deliberation --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["deliberation"], 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/motion.js, components/deliberation/deliberation.js, components/deliberation/deliberation.css, components/button/button.css, components/textarea/textarea.css, components/textarea/char-count.js.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="dl-demo" style="display:grid;gap:1.75rem;width:min(100%,68rem);margin-inline:auto">
<style>
#dl-demo .dl-bar { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem 1.5rem }
#dl-demo .dl-bar h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#dl-demo .dl-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#dl-demo .dl-controls { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem 1.25rem }
#dl-demo .dl-controls .mv-choice { font-size:.8125rem }
#dl-demo .dl-hint { margin:-.75rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
#dl-demo .dl-sep { height:1px; background:var(--mv-border) }
</style>
<div class="dl-bar">
<div>
<h3>Sprint 42 planning</h3>
<p>You are facilitating. Round 1 was far apart; round 2 is under way.</p>
</div>
<div class="dl-controls">
<label class="mv-choice">
<input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="dl-anon">
<span class="mv-choice-text"><span class="mv-choice-title">Anonymous</span></span>
</label>
<label class="mv-choice">
<input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="dl-timer">
<span class="mv-choice-text"><span class="mv-choice-title">60 s timer</span></span>
</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="dl-restart">Restart</button>
</div>
</div>
<mv-deliberation id="dl-estimate" scale="fibonacci" unit="pts" rule="supermajority" me="priya" facilitator
question="Offline sync for the mobile app (MOB-412): how many points?"></mv-deliberation>
<p class="dl-hint">Pick your card, then Reveal votes: every card flips at once · Start the next round and watch the convergence chart narrow</p>
<div class="dl-sep"></div>
<div class="dl-bar">
<div>
<h3>Release go/no-go</h3>
<p>Read-only room display · anonymous votes · unanimity required</p>
</div>
</div>
<mv-deliberation id="dl-go" scale="go" rule="unanimous"
question="Roll Checkout v3 out to 100% of traffic on Thursday?"></mv-deliberation>
<script type="module">
const est = document.getElementById("dl-estimate");
const go = document.getElementById("dl-go");
const $ = (id) => document.getElementById(id);
const team = [
{ id: "priya", name: "Priya Nair" },
{ id: "aiko", name: "Aiko Tanaka" },
{ id: "mateo", name: "Mateo Rossi" },
{ id: "lars", name: "Lars Eriksen" },
{ id: "amara", name: "Amara Okafor" },
{ id: "diego", name: "Diego Fernández" },
];
const hour = 36e5;
const start = Date.now() - hour / 2;
const seed = () => [
{ votes: { priya: 5, aiko: 13, mateo: 1, lars: 5, amara: 8, diego: 3 }, revealed: true, startedAt: start, revealedAt: start + 4 * 6e4 },
{ votes: { aiko: 8, lars: 5, amara: 8, diego: 5 }, startedAt: Date.now() - 45e3 },
];
// Teammates' next votes, round by round (the viewer votes for themself).
const plans = {
2: { mateo: 5 },
3: { aiko: 8, mateo: 5, lars: 5, amara: 5, diego: 5 },
};
const later = { aiko: 5, mateo: 5, lars: 5, amara: 5, diego: 8 };
let timers = [];
const schedule = (round) => {
timers.forEach(clearTimeout);
timers = [];
const plan = plans[round] ?? later;
Object.entries(plan).forEach(([id, value], i) => {
timers.push(setTimeout(() => { if (est.isConnected && est.round === round) est.vote(id, value); }, 900 + i * 650 + Math.random() * 400));
});
};
est.people = team;
est.rounds = seed();
await customElements.whenDefined("mv-deliberation");
schedule(2);
est.addEventListener("mv-round", (e) => schedule(e.detail.round));
$("dl-anon").addEventListener("change", (e) => { est.anonymous = e.target.checked; });
$("dl-timer").addEventListener("change", (e) => { est.timer = e.target.checked ? "60s" : null; });
$("dl-restart").addEventListener("click", () => {
est.decision = null;
est.rounds = seed();
schedule(2);
});
go.people = [
{ id: "noor", name: "Noor Haddad" },
{ id: "felix", name: "Felix Wagner" },
{ id: "sade", name: "Sade Adeyemi" },
{ id: "kenji", name: "Kenji Watanabe" },
{ id: "lucia", name: "Lucía Morales" },
{ id: "oren", name: "Oren Levi" },
];
const day = Date.now() - 2 * hour;
go.anonymous = true;
go.rounds = [
{ votes: { noor: "go", felix: "no-go", sade: "conditional", kenji: "go", lucia: "go", oren: "conditional" }, revealed: true, anonymous: true, startedAt: day },
{ votes: { noor: "go", felix: "conditional", sade: "conditional", kenji: "go", lucia: "conditional", oren: "conditional" }, revealed: true, anonymous: true, startedAt: day + 9e5 },
{ votes: { noor: "conditional", felix: "conditional", sade: "conditional", kenji: "conditional", lucia: "conditional", oren: "conditional" }, revealed: true, anonymous: true, startedAt: day + 18e5 },
];
go.decision = {
value: "conditional",
note: "Ship behind the checkout-v3 kill switch. Payments on-call confirms a rollback in under 5 minutes before the ramp goes past 25%.",
rule: "unanimous",
round: 3,
agree: 6,
counted: 6,
at: day + 21e5,
};
</script>
</div>Cultural reference
12 Angry Men — Sidney Lumet (from Reginald Rose's 1954 teleplay) (1957, film). A jury votes again and again behind closed doors, and round after round one juror's doubts, heard out instead of overruled, move the tally from 11 to 1 to a unanimous verdict. In the UI, a team votes in secret, reveals together, hears the voters far from the group first, votes again, and watches the spread narrow round by round until its consensus rule is met and the verdict is recorded with its reasons.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
people | string | Participants as comma-separated names ("Priya Nair, Aiko Tanaka"); ids are slugs of the names. Use the people property for ids, short names and avatars. | |
scale | fibonacci | tshirt | go | hire | confidence | "1-10" | list | fibonacci | Voting scale. Presets: fibonacci (0 1 2 3 5 8 13 21 ?), tshirt (XS…XXL), go (No-go, Go with conditions, Go), hire (Strong no hire … Strong hire), confidence (1–5 with hints). A range "1-10", or a list "1, 2, 3, 5, 8, ?" / "a:Option A, b:Option B" (value:label). "?" is neutral: shown, never counted. |
measure | numeric | ordinal | nominal | How distances are read. Auto: numeric when every value is a number, otherwise ordinal (steps along the scale). nominal (unordered choices, e.g. which feature first) drops median, range and splits; outliers become lone votes against a majority. | |
me | person id | The viewer. Shows the private ballot (a radio group) for this person, their own card face up with a “You” tag. Without it the component is a read-only display. | |
facilitator | boolean | Shows the facilitator controls: Reveal votes, Start round N, and the decision form (value + rationale) once the rule allows it. | |
anonymous | boolean | Rounds revealed while it is on are anonymous: cards are laid out by value with no names, chips carry no initials, sentences give counts instead of names. The flag is stored per round, so turning it off later never unmasks a past round. | |
rule | unanimous | supermajority | majority | facilitator | supermajority | Consensus rule checked at each reveal. facilitator: never met automatically, the facilitator makes the call after any reveal. |
threshold | ratio ("2/3", "0.75", "80%") | 2/3 | Share of counted votes a supermajority needs (abstentions, “?” and missing votes are not counted). |
tolerance | number (steps) | 0 | Votes within this many steps of the candidate agree with it (1 = neighbors on the scale, e.g. 5 and 8). Ignored for nominal. |
outlier | number (steps) | 2 | Distance from the median, in scale steps, from which a minority vote is named as far from the group. Also the gap that makes two camps a split. |
timer | time ("60s", "2m", ms) | Per-round countdown shown in the header (role="timer"). At zero, mv-timeout fires (cancelable) and the round is revealed. | |
auto-reveal | boolean | Reveals as soon as every participant has voted (after a short beat). | |
abstain | "true" | "false" | true | "false" removes the Abstain option from the ballot. |
max-rounds | number | After this many rounds without consensus the facilitator may decide anyway (“Round limit reached”). | |
question | string | The question being decided, shown as the title and used as the group's accessible name. | |
unit | string | Unit appended to numeric values in sentences, stats and the decision ("pts", "days"). | |
locale | BCP 47 tag | en-US | Locale for lists (“Aiko and Mateo”), numbers and the decision date. |
label | string | Accessible name of the group when question is not set (default “Group decision”). | |
data-phase / data-revealing | set by the component | data-phase="voting | revealed | decided"; data-revealing while mv-reveal waitUntil promises are pending. |
Properties
| Name | Type | Description |
|---|---|---|
people | Array<{ id?, name, short?, avatar?, role? }> | Participants. short is used in sentences (default: first word of name); avatar is an image URL (initials otherwise). |
options | Array<{ value, label?, short?, hint?, neutral? }> | Custom scale; overrides the scale attribute. short is used on cards and axes, hint under the ballot card, neutral options are shown but not counted. |
rounds | Array<{ votes: { [id]: value }, voted?: id[], revealed?, anonymous?, startedAt?, revealedAt? }> | Hydrate or read the whole history (silent: no events). The last round is the current one; voted lists people whose value is still secret. Reading returns copies with a round number. |
decision | { value, label, note, rule, met, round, agree, counted, at } | null | The recorded decision. Set it to hydrate a past decision; set null to reopen the discussion. |
round / phase / votes / stats | read-only | Current round number; "voting" | "revealed" | "decided"; current votes { id: value } (null for secret ones); analysis of the latest revealed round: { total, voted, counted, abstained, unsure, missing, distribution, median, mean, min, max, spread, candidate, agreement, outliers: [{ personId, value, distance }], split, consensus: { rule, met, value, agree, counted, needed } }. |
strings | Partial<Record<string, string>> | Overrides for every visible text, sentence and announcement ({placeholders}); English defaults. |
Methods
| Name | Description |
|---|---|
vote(personId, value) | Records a vote from your realtime feed: a scale value, "abstain", or null to withdraw. Emits the cancelable mv-vote (source "api"). Returns false outside the voting phase, for unknown people or values. |
markVoted(personId) | The person voted but the server keeps the value secret: the card turns face down with no value in the DOM. Supply values at reveal time. |
reveal(votes?) | Reveals the round (any number of votes). votes { id: value } fills secret values. Emits mv-reveal first, flips every card, then mv-revealed. Returns Promise<boolean>. |
nextRound() | Starts the next round with hidden votes; emits the cancelable mv-round. |
decide(value, note) | Records the decision for the current revealed round (any non-neutral scale value, whatever the rule: detail.met says whether it was met). Emits the cancelable mv-decision. |
reset() | Clears the history and the decision: one empty round. |
analyzeRound(votes, config) (module export) | The pure analysis used internally (Map of id → value, options, people, measure, rule, threshold, tolerance, outlierSteps), for server-side checks or tests. ABSTAIN is exported too. |
Events
| Name | Description |
|---|---|
mv-vote | Cancelable, before a vote is stored. detail: { personId, value (null = withdrawn), previous, round, source: "ui" | "api" }. Send ui votes to your backend here; preventDefault() rejects it (the ballot reverts). |
mv-reveal | Cancelable, before the reveal. detail: { round, source: "ui" | "api" | "timer" | "auto", voted, total, missing, waitUntil(promise) }. A waitUntil promise may resolve to { id: value } to disclose secret votes; a rejection cancels the reveal (announced, mv-reveal-error). |
mv-revealed | The cards have flipped. detail: { round, votes, stats, summary (the announced sentences), source }. |
mv-reveal-error | A waitUntil promise rejected. detail: { round, error }. |
mv-round | Cancelable, before a new round starts. detail: { round (the new number), previous (stats of the round just discussed), source }. |
mv-decision | Cancelable, before the decision is recorded. detail: { value, label, note, rule, met, round, agree, counted, stats, source }. |
mv-timeout | Cancelable: the round timer reached zero. preventDefault() keeps the round open (the timer is hidden). |
CSS classes
| Name | Description |
|---|---|
mv-deliberation-head / -kicker / -question / -rule / -timer | Header: round and phase, question, rule and anonymity chips, countdown with its ring (data-urgent in the last 10 s). |
mv-deliberation-seats / -seat / -card | The table. Each seat has data-state="waiting | voted | revealed | abstained | missing", data-me, data-own (own value shown), data-outlier, data-agree (with the met consensus), data-anonymous. |
mv-deliberation-toolbar / -progress / -reveal / -next | Vote count, progress bar and facilitator buttons (mv-button classes). |
mv-deliberation-ballot / -option / -option-face | The viewer's private ballot: a fieldset of native radios styled as cards; data-locked after the reveal. |
mv-deliberation-results / -stats / -dist / -col / -chip / -median / -insights | Revealed round: stats, distribution columns (data-candidate, data-window), voter chips (data-outlier), median marker and insight sentences (li[data-kind="count | consensus | pending | facilitator | outliers | split"]). |
mv-deliberation-history / -chart / -row / -track / -range / -dot / -med / -axis | Convergence chart: one row per round (data-current, data-pending for the round being voted), range bar, value dots sized by share, median marker, agreement share. |
mv-deliberation-decide / -record | Decision form (value chips, rationale textarea) and the recorded decision card. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-deliberation-accent | var(--mv-accent) | Ballot selection, consensus candidate, median marker. |
--mv-deliberation-outlier | var(--mv-warning) | Tint of the voters far from the group (always paired with a dashed outline, a flag and words). |
--mv-deliberation-agree | var(--mv-success) | Consensus reached: agreeing cards, chart dot, decision card. |
--mv-deliberation-card-back | var(--mv-accent) | Tint of the face-down card back pattern. |
Accessibility
The component is a labelled group (the question). Seats are a list: each item reads the name and a status (“Thinking…”, “Voted”, “Your vote: 5 pts”, then the revealed value, “far from group”, “Abstained” or “No vote”); the card graphics are aria-hidden, and hidden values never exist in the DOM before the reveal (except the viewer's own). The ballot is a native fieldset with a legend (“Your vote · Round 2”) and radios, so arrow keys, Tab and screen readers work as in any form; Abstain is a radio too and Withdraw is a real button. Reveal, Start round and Record decision are native buttons (aria-busy while revealing); after a reveal from the button, focus moves to Start round, after a new round to the ballot, after a decision to the decision card. Results are given three ways: visible sentences (counts, median and range, consensus, outliers named with their values, or the two camps of a split), a visually hidden table per round (voter, vote, note; value and count in anonymous rounds) and a visually hidden table of all rounds for the convergence chart, whose drawing is aria-hidden. A polite live region announces “Everyone has voted.”, the full summary at each reveal, each new round, the 10-second warning and the decision; a failed reveal is announced assertively. The timer is role="timer" with a spoken aria-label and is not live. Nothing relies on color alone: outliers have a dashed outline, a flag and the words “Far from group”, consensus has a check and a sentence, the median has a marker, anonymous rounds say so. Reduced motion (prefers-reduced-motion or data-motion="reduce"): cards swap faces instantly, no lift and no breathing on waiting seats. Forced colors: cards, tracks and chips use system colors, the selected option and the consensus use Highlight.