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.

CategoryData display
TypeWeb Component (<mv-deliberation>)
Statusbeta
Also installsbutton, textarea
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs deliberation --out ./src/marvelous

AI 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

NameTypeDefaultDescription
peoplestringParticipants as comma-separated names ("Priya Nair, Aiko Tanaka"); ids are slugs of the names. Use the people property for ids, short names and avatars.
scalefibonacci | tshirt | go | hire | confidence | "1-10" | listfibonacciVoting 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.
measurenumeric | ordinal | nominalHow 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.
meperson idThe 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.
facilitatorbooleanShows the facilitator controls: Reveal votes, Start round N, and the decision form (value + rationale) once the rule allows it.
anonymousbooleanRounds 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.
ruleunanimous | supermajority | majority | facilitatorsupermajorityConsensus rule checked at each reveal. facilitator: never met automatically, the facilitator makes the call after any reveal.
thresholdratio ("2/3", "0.75", "80%")2/3Share of counted votes a supermajority needs (abstentions, “?” and missing votes are not counted).
tolerancenumber (steps)0Votes within this many steps of the candidate agree with it (1 = neighbors on the scale, e.g. 5 and 8). Ignored for nominal.
outliernumber (steps)2Distance 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.
timertime ("60s", "2m", ms)Per-round countdown shown in the header (role="timer"). At zero, mv-timeout fires (cancelable) and the round is revealed.
auto-revealbooleanReveals as soon as every participant has voted (after a short beat).
abstain"true" | "false"true"false" removes the Abstain option from the ballot.
max-roundsnumberAfter this many rounds without consensus the facilitator may decide anyway (“Round limit reached”).
questionstringThe question being decided, shown as the title and used as the group's accessible name.
unitstringUnit appended to numeric values in sentences, stats and the decision ("pts", "days").
localeBCP 47 tagen-USLocale for lists (“Aiko and Mateo”), numbers and the decision date.
labelstringAccessible name of the group when question is not set (default “Group decision”).
data-phase / data-revealingset by the componentdata-phase="voting | revealed | decided"; data-revealing while mv-reveal waitUntil promises are pending.

Properties

NameTypeDescription
peopleArray<{ id?, name, short?, avatar?, role? }>Participants. short is used in sentences (default: first word of name); avatar is an image URL (initials otherwise).
optionsArray<{ 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.
roundsArray<{ 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 } | nullThe recorded decision. Set it to hydrate a past decision; set null to reopen the discussion.
round / phase / votes / statsread-onlyCurrent 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 } }.
stringsPartial<Record<string, string>>Overrides for every visible text, sentence and announcement ({placeholders}); English defaults.

Methods

NameDescription
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

NameDescription
mv-voteCancelable, 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-revealCancelable, 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-revealedThe cards have flipped. detail: { round, votes, stats, summary (the announced sentences), source }.
mv-reveal-errorA waitUntil promise rejected. detail: { round, error }.
mv-roundCancelable, before a new round starts. detail: { round (the new number), previous (stats of the round just discussed), source }.
mv-decisionCancelable, before the decision is recorded. detail: { value, label, note, rule, met, round, agree, counted, stats, source }.
mv-timeoutCancelable: the round timer reached zero. preventDefault() keeps the round open (the timer is hidden).

CSS classes

NameDescription
mv-deliberation-head / -kicker / -question / -rule / -timerHeader: round and phase, question, rule and anonymity chips, countdown with its ring (data-urgent in the last 10 s).
mv-deliberation-seats / -seat / -cardThe 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 / -nextVote count, progress bar and facilitator buttons (mv-button classes).
mv-deliberation-ballot / -option / -option-faceThe 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 / -insightsRevealed 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 / -axisConvergence 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 / -recordDecision form (value chips, rationale textarea) and the recorded decision card.

CSS variables

NameDefaultDescription
--mv-deliberation-accentvar(--mv-accent)Ballot selection, consensus candidate, median marker.
--mv-deliberation-outliervar(--mv-warning)Tint of the voters far from the group (always paired with a dashed outline, a flag and words).
--mv-deliberation-agreevar(--mv-success)Consensus reached: agreeing cards, chart dot, decision card.
--mv-deliberation-card-backvar(--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.