Exclusivebeta

Turn Clock — <mv-turn-clock>

Fair-time turn manager for standups, retros, debates, interviews, panels and mob-programming rotations, built like a tournament chess clock: every participant owns a time bank (equal, or custom per person) that drains only during their own turn, exactly one clock runs at a time, and passing the turn (big Pass button, Space, a digit key or a click on the person) stops yours and starts the next one, with an optional per-turn increment. The current speaker is staged big with a draining bar, each roster row shows its own bank as a bar and a clock, and running out never raises an alarm: the time goes negative behind a calm striped pattern and an “Overtime” tag (or, with on-empty, passes on or pauses by itself). The header tracks the meeting against its plan; order can be round robin, random or quietest first (fewest turns always go first), people can be marked away, a mistaken pass can be undone with its time handed back, and rounds can close the session on its own. Finishing produces an airtime summary (share per person against their allotted share, turns, average turn, overtime and a balance score) that copies as text or downloads as CSV. Time is derived from clock offsets, never counted ticks (robust to background tabs), sessions survive reloads with persist, and getSnapshot()/restore() let you sync or resume them.

CategoryData display
TypeWeb Component (<mv-turn-clock>)
Statusbeta
Also installsbutton, kbd
Keywordsexclusive, culture, timer, turn-taking, meeting, standup, retro, debate, facilitation, chess-clock, time-bank, speaking-time, fairness, airtime, mob-programming, rotation, countdown, csv-export, keyboard

When to use

Avoid when

Install

node scripts/add.mjs turn-clock --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["turn-clock"], 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/focus.js, components/turn-clock/turn-clock.js, components/turn-clock/turn-clock.css, components/button/button.css, components/kbd/kbd.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="tc-demo" style="width:min(100%,56rem);margin-inline:auto">
  <style>
    #tc-demo { display:grid; gap:1.25rem; align-content:start }
    #tc-demo .tc-controls { display:flex; flex-wrap:wrap; align-items:center; gap:.75rem 1.25rem; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
    #tc-demo .tc-field { display:inline-flex; align-items:center; gap:.5rem; font-size:.8125rem; color:var(--mv-fg-muted) }
    #tc-demo .tc-field .mv-select { width:auto; min-width:10.5rem }
    #tc-demo .mv-choice { font-size:.8125rem }
    #tc-demo .tc-spacer { flex:1 1 0 }
    #tc-demo .tc-log { display:flex; align-items:center; gap:.625rem; min-height:1.5rem; margin:0; color:var(--mv-fg-muted); font:.75rem/1.4 var(--mv-font-mono) }
    #tc-demo .tc-sub { display:grid; gap:.625rem }
    #tc-demo .tc-sub h4 { margin:0; font-size:.875rem; font-weight:600 }
    #tc-demo .tc-sub p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem }
  </style>

  <!-- Main: a daily standup, already under way (restored from a snapshot below) -->
  <mv-turn-clock id="tc-standup" label="Daily standup · Platform team" bank="2m" planned="13m" rounds="1" hotkeys="global">
    <span data-person="amara" data-role="Backend · payments">Amara Okafor</span>
    <span data-person="kenji" data-role="Frontend · checkout">Kenji Watanabe</span>
    <span data-person="priya" data-role="Product design">Priya Raman</span>
    <span data-person="lucas" data-role="QA automation">Lucas Moreau</span>
    <span data-person="sofia" data-role="Data engineering">Sofia Almeida</span>
    <span data-person="daniel" data-role="Engineering lead" data-bank="3m">Daniel Kim</span>
  </mv-turn-clock>

  <div class="tc-controls">
    <label class="tc-field">Order
      <select class="mv-select" id="tc-order" data-size="sm">
        <option value="round-robin">Round robin</option>
        <option value="random">Random</option>
        <option value="least-spoken">Quietest first</option>
      </select>
    </label>
    <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tc-inc"> +10 s per turn</label>
    <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tc-auto"> Auto-pass at zero</label>
    <span class="tc-spacer"></span>
    <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="tc-ff">Skip ahead 45 s</button>
    <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="tc-replay">Replay demo</button>
  </div>
  <p class="tc-log"><span class="mv-badge" data-variant="secondary" id="tc-event">mv-turn</span><span id="tc-log">Priya Raman took the floor from Kenji Watanabe.</span></p>

  <!-- Two-sided debate: equal banks, a 15-second increment, paused mid-way -->
  <div class="tc-sub">
    <div>
      <h4>Architecture review · two-sided debate</h4>
      <p>A true chess clock: 3 minutes per side, 15 seconds back on every hand-over. Shortcuts work while focus is inside it.</p>
    </div>
    <mv-turn-clock id="tc-debate" label="Proposal: move billing to event sourcing" bank="3m" increment="15s" warn="30s">
      <span data-person="for" data-role="For the proposal">Lucía Fernández</span>
      <span data-person="against" data-role="Against the proposal">Tomás Novak</span>
    </mv-turn-clock>
  </div>

  <script type="module">
    const standup = document.getElementById("tc-standup");
    const debate = document.getElementById("tc-debate");
    const log = document.getElementById("tc-log");
    const badge = document.getElementById("tc-event");
    const say = (event, text) => { badge.textContent = event; log.textContent = text; };
    const s = (n) => n * 1000;

    // A standup that started 4 min 44 s ago: Amara and Kenji are done, Priya is speaking.
    function seedStandup() {
      const now = Date.now();
      standup.restore({
        state: "running", current: "priya", elapsed: s(52), savedAt: now, startedAt: now - s(94 + 138 + 52),
        people: [
          { id: "amara", bank: s(26), spent: s(94), turns: 1, longest: s(94) },
          { id: "kenji", bank: -s(18), spent: s(138), turns: 1, longest: s(138), overtime: s(18) },
          { id: "priya", bank: s(120) }, { id: "lucas", bank: s(120) }, { id: "sofia", bank: s(120) }, { id: "daniel", bank: s(180) },
        ],
        log: [
          { id: "amara", name: "Amara Okafor", start: 0, duration: s(94) },
          { id: "kenji", name: "Kenji Watanabe", start: s(94), duration: s(138) },
        ],
      });
    }

    // Debate: Lucía opened (two turns), Tomás holds the floor, the moderator paused.
    function seedDebate() {
      const now = Date.now();
      debate.restore({
        state: "paused", current: "against", elapsed: s(41), savedAt: now, startedAt: now - s(260),
        people: [
          { id: "for", bank: s(180 - 71 - 58 + 30), spent: s(129), turns: 2, longest: s(71) },
          { id: "against", bank: s(180 - 49 + 15), spent: s(49), turns: 1, longest: s(49) },
        ],
        log: [
          { id: "for", name: "Lucía Fernández", start: 0, duration: s(71) },
          { id: "against", name: "Tomás Novak", start: s(71), duration: s(49) },
          { id: "for", name: "Lucía Fernández", start: s(120), duration: s(58) },
        ],
      });
    }

    const who = (p) => (p ? p.name : "nobody");
    standup.addEventListener("mv-turn", (e) => {
      const { from, to, reason } = e.detail;
      say("mv-turn", reason === "start" ? `${who(to)} opened the session.` : reason === "undo" ? `Undo: back to ${who(to)}.` : `${who(to)} took the floor from ${who(from)}${reason === "timeout" ? " (time ran out)" : ""}.`);
    });
    standup.addEventListener("mv-warn", (e) => say("mv-warn", `${e.detail.person.name} has 1 minute left.`));
    standup.addEventListener("mv-overtime", (e) => say("mv-overtime", `${e.detail.person.name} is over time. Nothing flashes: the clock just goes negative.`));
    standup.addEventListener("mv-finish", (e) => {
      const { summary } = e.detail;
      say("mv-finish", `Finished in ${Math.round(summary.duration / 1000)} s · airtime balance ${summary.balance === null ? "n/a" : Math.round(summary.balance * 100) + "%"}.`);
    });
    standup.addEventListener("mv-away", (e) => say("mv-away", `${e.detail.person.name} is ${e.detail.away ? "away" : "back"}.`));

    document.getElementById("tc-order").addEventListener("change", (e) => { standup.order = e.target.value; });
    document.getElementById("tc-inc").addEventListener("change", (e) => { standup.increment = e.target.checked ? "10s" : "0"; });
    document.getElementById("tc-auto").addEventListener("change", (e) => { standup.onEmpty = e.target.checked ? "pass" : "continue"; });
    document.getElementById("tc-ff").addEventListener("click", () => {
      // Fast-forward the running turn by resuming a snapshot 45 s older.
      if (standup.state !== "running" && standup.state !== "paused") return;
      const snap = standup.getSnapshot();
      snap.elapsed += 45_000;
      snap.startedAt -= 45_000;
      standup.restore(snap);
    });
    document.getElementById("tc-replay").addEventListener("click", () => {
      standup.reset();
      seedStandup();
      say("mv-turn", "Priya Raman took the floor from Kenji Watanabe.");
    });

    customElements.whenDefined("mv-turn-clock").then(() => { seedStandup(); seedDebate(); });
  </script>
</div>

Cultural reference

The Queen's Gambit — Walter Tevis (novel, 1983; Netflix series by Scott Frank & Allan Scott, 2020) (1983, book). In tournament chess each player has a time bank that runs only during their own move, and pressing the clock ends your turn and starts your opponent's, so time is shared fairly and visibly. Here every meeting participant gets such a bank: one clock runs at a time, passing hands the floor to the next person, and the session ends with a fair-airtime summary.

API

Attributes

NameTypeDefaultDescription
banktime ("2m", "90s", "1m30s", ms number)2mTime bank given to every participant who has no data-bank of their own. Changing it mid-session shifts the remaining bank of those people by the difference.
incrementtime0Time added to a person's bank each time they end a turn by passing (a chess-style increment). Turns shorter than one second (an accidental double pass) earn nothing and don't count as a turn.
warntime1mRemaining time under which the speaker's clock turns to the low tone and “1 minute left for …” is announced once (skipped when the turn already starts below it).
plannedtimePlanned meeting length shown in the header meter (“of 15:00 planned”, then “1:12 over plan”). Defaults to the sum of the present people's banks times rounds.
orderround-robin | random | least-spokenround-robinWho a plain pass goes to. round-robin and random always pick among the people with the fewest turns so far (list order after the current speaker, or a random one that stays shown as “Up next” until the pass); least-spoken picks whoever has spoken the least time. Clicking a person or pressing their digit always overrides it.
roundsnumber0When set, the session finishes by itself once every present person has had that many turns (the Pass button reads “Pass & finish” on the last turn). 0 = unlimited.
on-emptycontinue | pass | pausecontinueWhat happens when the speaker's bank reaches zero: continue into calm negative overtime, pass to the next person at the exact moment of zero (the overshoot of a throttled tab is carried over), or pause the session.
hotkeyslocal | global | offlocalWhere the keyboard shortcuts listen: while focus is inside the component, anywhere on the page (never while typing in a field, and never for keys aimed at another turn clock), or nowhere.
labelstringTurn clockSession name shown in the header; also the accessible name of the region and the CSV file name.
persiststringlocalStorage key suffix: the session is saved on every change and when the page is hidden, and restored on load, so a reload or a crashed tab resumes where it was (running time included).
soundbooleanOpt-in: one soft two-note tone when a bank runs out. Only after the user has interacted with the page, never an alarm loop.
data-person / data-role / data-bank / data-avatar / data-awayon child elementsEach child element is a participant: its text is the name, data-person the stable id (defaults to a slug of the name), data-role a subtitle, data-bank a custom time bank, data-avatar an image URL (initials otherwise), data-away starts them out of the rotation. Children are re-read when a framework re-renders them; stats are kept by id.
data-state / data-phaseset by the componentOn the host: ready | running | paused | finished, and ok | low | over for the current speaker. Rows get data-status (speaking, paused, next, done, idle, away) and data-phase.

Properties

NameTypeDescription
peopleArray<string | { id?, name, role?, bank?, avatar?, away? }>Set to replace the child-element roster (stats are kept for matching ids). Read it for live person objects { id, name, role, index, allotted, remaining, spent, turns, away }.
state"ready" | "running" | "paused" | "finished"Session state (read-only).
current / nextperson | nullThe person holding the turn, and who a plain pass would give it to (the first speaker before start). Read-only.
elapsednumber (ms)Meeting time so far, pauses excluded (read-only).
summaryobjectLive airtime summary (final once finished): { label, state, startedAt, endedAt, duration, planned, overPlan, turns, averageTurn, balance (Jain's fairness index of time spent relative to each allotment, 1 = exactly as allotted, null before anyone spoke), people: [{ id, name, role, allotted, spent, share, expected, turns, average, longest, overtime, remaining, away }], log: [{ id, name, start, duration }] }.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (English defaults; placeholders like {name}, {time}, {n}).

Methods

NameDescription
start(who?)Starts the session with a person (id, index or person object) or the first in order. Returns false if not ready or cancelled.
pass()Ends the current turn and gives it to next (starts the session when ready, finishes it when the last round completes).
passTo(who)Gives the turn to a specific person, bringing them back if they were away.
pause() / resume() / toggle()Freezes or restarts the running clock.
undo()Gives the turn back to the previous speaker and charges them the time since the pass (their bank, turn count and increment are restored). Up to 50 passes deep.
finish({ reason? })Ends the session (emits the cancelable mv-finish) and shows the summary.
reset()New session: banks refilled, stats and log cleared, back to ready.
setAway(who, away = true)Takes someone out of the rotation (a current speaker passes on first) or brings them back.
adjust(who, time)Adds time to a bank, or removes it with a negative value ("30s", -15000): a bonus, a penalty, a time-out.
getSnapshot() / restore(snapshot)Serializable session state and its restoration (a running snapshot keeps counting the time since it was saved). Use it to sync a facilitator's screen to a shared display or resume a session server-side.
toText() / toCSV()The airtime summary as paste-ready plain text, or as CSV (one row per person plus totals).

Events

NameDescription
mv-turnCancelable, before the turn changes. detail: { from (person | null at start), to, reason: "start" | "pass" | "pick" | "timeout" | "away" | "undo", duration (ms of the turn that ends) }. preventDefault() keeps the current speaker.
mv-warnThe speaker crossed the warn threshold. detail: { person, remaining }.
mv-overtimeThe speaker's bank reached zero during this turn. detail: { person, overtime }.
mv-finishCancelable, before the session ends. detail: { summary, reason: "button" | "rounds" | "timeout" | "api" }.
mv-awaySomeone was marked away or back. detail: { person, away }.

Content structure

NameDescription
(content)One child element per participant (see data-person…). They are hidden and only read as data; the UI is rendered in .mv-turn-clock-ui.

CSS classes

NameDescription
mv-turn-clock-headHeader: -title, -meta, -pill (state), -elapsed, -planned and -meter (meeting vs plan).
mv-turn-clock-stageCurrent speaker: -avatar[data-size=lg], -eyebrow (with the -eq speaking bars), -name, -role, -digits, -over-tag, -sub, -drain (bank bar), -next, -undo, -pause, -pass.
mv-turn-clock-row / mv-turn-clock-pick / mv-turn-clock-awayRoster row, its main button (-index, -avatar, -person-name, -bar, -time, -status) and the Away toggle.
mv-turn-clock-summaryAfter finish: -stats (dl), -table (with -share bars and -share-mark for the allotted share), export buttons.
mv-turn-clock-footShortcut legend (-keys) and the Finish button.

CSS variables

NameDefaultDescription
--mv-turn-clock-accentvar(--mv-accent)Current speaker, bars and airtime shares.
--mv-turn-clock-lowvar(--mv-warning)Tone under the warn threshold.
--mv-turn-clock-overvar(--mv-info)Overtime tone and striped pattern (deliberately calm, not danger red).
--mv-turn-clock-avatar-bg / --mv-turn-clock-avatar-fghue derived from the idOverride the initials avatar colors.

Accessibility

The host is a labelled region. The current speaker and their time live in a visually hidden role="timer" element (implicit aria-live off): it is always up to date for screen readers that read it on demand, but never chatters every second. A separate polite live region speaks only on real events: turn changes (“Kenji Watanabe’s turn. 2 minutes left.”), the warn threshold (“1 minute left for Kenji Watanabe.”), overtime (“Kenji Watanabe is over time.”), pause, resume, undo, away and finish. Every control is a real button: the roster is an ordered list with one tab stop (arrow keys, Home and End move between people, Right reaches the Away toggle, Left comes back), the speaker's row has aria-current, and each row is described by a sentence (time left or over, turns, status). Shortcuts are listed in a visible legend and exposed with aria-keyshortcuts: Space or N passes (Space still activates a focused button natively), 1–9 give the turn to that person, P pauses and resumes, Backspace or Ctrl/Cmd+Z undoes the last pass; they never fire while typing in a field, and hotkeys="global" makes them work from anywhere on the page. States never rely on color alone: speaking, paused, up next and away each have a word and a distinct marker, overtime shows a minus sign, a striped pattern and an “Overtime” tag, away people are struck through. Finishing moves focus to the summary title when focus was inside the component; the summary is a real table with a caption, row headers and a sentence per share (“23% of the airtime, 17% allotted”). Reduced motion (OS setting or data-motion="reduce"): bars step each second instead of gliding and the speaking bars stand still. Forced colors: bars get system-color borders, fills use Highlight and overtime a CanvasText hatch. Overtime is never an alarm: no flashing, no red by default, sound is opt-in and plays once.