Exclusivebeta

Tone Dial — <mv-tone-dial>

Personality and tone settings with an immediate, concrete preview, for AI assistants, chatbots, generated copy, notification or email tone, brand voice and support macros. The app declares traits (Formality, Warmth, Humor, Detail, Directness, Emoji use by default), each set from 0 to 100% on a native range drawn as zone segments (“Terse · Balanced · Thorough”) with the active zone emphasized, a numeric field (type 75), digits typed straight on a focused slider, Shift+arrows by 10, Page Up/Down to the next mark, magnetic snapping to marks while dragging, a notch showing each default and a per-trait reset. The key part is the live sample: the app's preview(settings) function (sync, or async with debounce, abort signal, loading and error states) rewrites it on every move, and the words that changed since the last setting are highlighted in place (or shown as insertions and deletions with diff="inline"), with a status line such as “Humor 20% → 72% · +18 −4 words”. Incompatible combinations raise gentle notes (high humor with high formality) that flag the traits involved. Presets are toggle chips (the one matching the settings is pressed, a tweaked one shows “edited”), user presets can be saved, deleted and persisted, and switching glides every slider to its new value. Sample, Prompt (a system-prompt-style snippet built from per-zone guidance) and JSON views each copy to the clipboard through a cancelable mv-export. Form-associated (JSON or prompt text), with mv-input, mv-change, mv-preview, mv-hint and a cancelable mv-preset.

CategoryForms
TypeWeb Component (<mv-tone-dial>)
Statusbeta
Also installsbutton
Keywordsexclusive, culture, form, tone, voice, personality, brand-voice, ai, llm, chatbot, system-prompt, copywriting, preview, diff, presets, slider, range, settings, notifications, export, json

When to use

Avoid when

Install

node scripts/add.mjs tone-dial --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["tone-dial"], 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/form.js, core/motion.js, components/tone-dial/tone-dial.js, components/tone-dial/tone-dial.css, components/button/button.css.

Usage

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

<div id="td-demo" style="width:min(100%,72rem);margin-inline:auto">
  <style>
    #td-demo { display:grid; gap:1.5rem; align-content:start }
    #td-demo .td-msg { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.125rem .625rem; align-items:start; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted); font-size:.8125rem; line-height:1.5 }
    #td-demo .td-av { grid-row:span 2; display:grid; place-items:center; width:1.875rem; height:1.875rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.6875rem; font-weight:650 }
    #td-demo .td-who { display:flex; gap:.5rem; align-items:baseline; flex-wrap:wrap }
    #td-demo .td-who b { font-weight:600 }
    #td-demo .td-who span { color:var(--mv-fg-muted); font-size:.75rem }
    #td-demo .td-msg p { margin:0; color:var(--mv-fg) }
    #td-demo .td-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin-top:.875rem }
    #td-demo .td-out { flex:1 1 20rem; min-width:0; margin:0; color:var(--mv-fg-muted); font:.75rem/1.5 var(--mv-font-mono); overflow-wrap:anywhere }
    #td-demo .td-out b { color:var(--mv-fg); font-weight:600 }
    #td-demo .td-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #td-demo .td-caption { margin:0 0 .625rem; color:var(--mv-fg-muted); font-size:.8125rem }
    #td-demo .td-caption b { color:var(--mv-fg); font-weight:600 }
  </style>

  <!-- 1 · AI support assistant: sync preview from a deterministic phrase-bank generator -->
  <form id="td-form">
    <mv-tone-dial id="td-main" name="voice" label="Support assistant voice"
      description="How the AI assistant answers customers on email and chat. Every change rewrites the sample reply.">
      <div slot="context" class="td-msg">
        <span class="td-av" aria-hidden="true">PS</span>
        <span class="td-who"><b>Priya Shah</b><span>Order #48213 · 9:41 AM</span></span>
        <p>Hi, my order was supposed to arrive Monday and it still hasn’t. What’s going on?</p>
      </div>
      <script type="application/json">
        { "presets": [
          { "id": "support", "label": "Support agent", "description": "The product defaults", "settings": {} },
          { "id": "enterprise", "label": "Enterprise account", "settings": { "formality": 85, "warmth": 45, "humor": 0, "detail": 75, "directness": 75, "emoji": 0 } },
          { "id": "community", "label": "Community forum", "settings": { "formality": 15, "warmth": 80, "humor": 70, "detail": 40, "directness": 50, "emoji": 70 } },
          { "id": "incident", "label": "Incident update", "settings": { "formality": 60, "warmth": 30, "humor": 0, "detail": 90, "directness": 90, "emoji": 0 } }
        ] }
      </script>
    </mv-tone-dial>
    <div class="td-bar">
      <p class="td-out" id="td-out" aria-live="polite">Submitted value appears here.</p>
      <div class="td-actions">
        <button class="mv-button" data-variant="outline" type="reset">Discard changes</button>
        <button class="mv-button" type="submit">Save voice</button>
      </div>
    </div>
  </form>

  <!-- 2 · Push notification tone: async preview (simulated 450 ms server call) with its own traits and a conflict note -->
  <section>
    <p class="td-caption"><b>Async preview</b> · the notification copy comes from a (simulated) server call, debounced while you drag.</p>
    <mv-tone-dial id="td-push" label="Failed payment reminder" description="Push notification sent when a Lumen Pro renewal can’t be charged." debounce="350"
      value='{"urgency":75,"warmth":72,"length":45}'>
      <script type="application/json">
        {
          "traits": [
            { "id": "urgency", "label": "Urgency", "value": 45, "zones": [
              { "label": "Calm", "prompt": "Reassure; the issue fixes itself if ignored for a while." },
              { "label": "Clear", "prompt": "State the problem and the one action plainly." },
              { "label": "Urgent", "prompt": "Lead with the deadline and the consequence." } ] },
            { "id": "warmth", "label": "Warmth", "value": 50, "zones": [
              { "label": "Neutral", "prompt": "Plain and factual." },
              { "label": "Kind", "prompt": "Polite, one friendly touch." },
              { "label": "Caring", "prompt": "Apologize for the hassle and offer help." } ] },
            { "id": "length", "label": "Length", "value": 40, "marks": [0, 33, 50, 67, 100], "zones": [
              { "label": "Short", "prompt": "One line." },
              { "label": "Standard", "prompt": "Two short sentences." },
              { "label": "Detailed", "prompt": "Amount, card, retry date and consequence." } ] }
          ],
          "presets": [
            { "id": "gentle", "label": "Gentle reminder", "settings": { "urgency": 20, "warmth": 70, "length": 50 } },
            { "id": "final", "label": "Final notice", "settings": { "urgency": 90, "warmth": 20, "length": 80 } }
          ],
          "hints": [
            { "when": { "urgency": ">=67", "warmth": ">=67" }, "message": "Very urgent and very warm at once can read as alarmed. Let one of them lead." }
          ]
        }
      </script>
    </mv-tone-dial>
  </section>

  <script type="module">
    await customElements.whenDefined("mv-tone-dial");
    const main = document.getElementById("td-main");
    const push = document.getElementById("td-push");
    const zone = (v) => (v < 34 ? 0 : v < 67 ? 1 : 2);
    const pick = (i, list) => list[i];

    // Formal voice: no contractions.
    const EXPAND = { "I'm": "I am", "I've": "I have", "I'd": "I would", "it's": "it is", "It's": "It is", "didn't": "did not", "hasn't": "has not", "doesn't": "does not", "we'll": "we will", "can't": "cannot", "Can't": "Cannot", "there's": "there is", "that's": "that is", "couldn't": "could not", "We'll": "We will", "We're": "We are" };
    const formal = (t) => t.replace(/\b[A-Za-z]+'[a-z]+\b/g, (w) => EXPAND[w] ?? w);

    // 1 · Support reply generator: phrase banks per trait zone, no AI involved.
    function supportReply(s) {
      const F = zone(s.formality), W = zone(s.warmth), H = zone(s.humor), D = zone(s.detail), R = zone(s.directness), E = zone(s.emoji);
      const greet = (F === 2 ? (W === 2 ? "Dear Priya," : "Dear Ms. Shah,") : F === 1 ? "Hi Priya," : W ? "Hey Priya!" : "Hey Priya,") + (E ? " 👋" : "");
      const ack = pick(W, [
        "",
        F === 2 ? "Thank you for contacting us about order #48213." : "Thanks for reaching out about order #48213.",
        F === 2 ? "I sincerely apologize that order #48213 did not arrive on Monday as promised." : F === 1 ? "I'm really sorry your order #48213 didn't show up on Monday. I know you were counting on it." : "So sorry your order #48213 didn't make it Monday, that's frustrating!",
      ]);
      const it = W ? "it" : "your order #48213";
      const answer = pick(R, [
        `From what I can see, ${it} may now arrive around Friday, September 25.`,
        W ? "The good news: it's on its way and should arrive Friday, September 25." : "Your order #48213 is on its way and should arrive Friday, September 25.",
        W ? "It will arrive Friday, September 25." : "Your order #48213 will arrive Friday, September 25.",
      ]) + (E === 2 ? " 🚚" : "");
      const detail = [
        D >= 1 ? (F === 2 ? "The parcel was held at the carrier's Memphis hub following last weekend's storm." : "It was held at the carrier's Memphis hub after last weekend's storm.") : "",
        D === 2 ? "It left the hub this morning; you can follow it with tracking number 1Z 84F 2E0 36 8120 4471. If it hasn't arrived by Saturday, reply to this email and we'll ship a replacement at no cost." : "",
      ];
      const joke = pick(H, [
        "",
        "Even parcels need a rain delay now and then.",
        F === 2 ? "It appears the parcel took an unscheduled layover in Memphis to wait out the weather." : `Apparently your parcel decided to wait out the storm in Memphis. Can't blame it, but it's back on the road now.${E ? " 📦" : ""}`,
      ]);
      const offer = W === 2 || D === 2 ? pick(R, [
        "If it helps, I'd be happy to refund the $7.95 shipping fee.",
        "I've gone ahead and refunded the $7.95 shipping fee.",
        "I've refunded the $7.95 shipping fee to your card.",
      ]) : "";
      const close = (W === 0
        ? (F === 2 ? "Please contact us if the parcel does not arrive." : "Let us know if it doesn't arrive.")
        : pick(F, ["Anything else, just shout!", "Let me know if there's anything else I can do.", "Please do not hesitate to contact us should you need anything further."])) + (E === 2 ? " 🙌" : "");
      const sign = pick(F, [`Cheers,\nAlex${E === 2 ? " 🙂" : ""}`, "Best,\nAlex", "Kind regards,\nAlex Moreau\nCustomer Care, Juniper & Co."]);
      const join = (...parts) => parts.flat().filter(Boolean).join(" ");
      const text = [greet, join(ack, answer, detail), joke, join(offer, close), sign].filter(Boolean).join("\n\n");
      return F === 2 ? formal(text) : text;
    }
    main.preview = (settings) => supportReply(settings);

    // Show the mechanic right away: the assistant was just made more playful.
    main.set("humor", 72, { emit: false });

    // Form association: the submitted value is the settings JSON.
    const form = document.getElementById("td-form");
    const out = document.getElementById("td-out");
    form.addEventListener("submit", (e) => {
      e.preventDefault();
      const v = new FormData(form).get("voice");
      out.replaceChildren(Object.assign(document.createElement("b"), { textContent: "Saved voice = " }), v);
    });
    form.addEventListener("reset", () => { out.textContent = "Changes discarded."; });

    // 2 · Async generator: resolves after a fake round trip, honors the abort signal.
    function notification(s) {
      const U = zone(s.urgency), W = zone(s.warmth), L = zone(s.length);
      const title = pick(U, ["About your Lumen Pro payment", "Payment failed for Lumen Pro", "Action needed: your Lumen Pro payment failed"]);
      const body = [
        W === 2 ? "Sorry for the hassle!" : "",
        pick(L, [
          "Update your card to keep access.",
          "We couldn't charge your Visa ending 4417 for $29.00. Update your card to keep access.",
          "We couldn't charge your Visa ending 4417 for $29.00 on September 24. We'll retry on September 27; update your card before then to keep your projects and exports available.",
        ]),
        pick(U, [L ? "No rush, we'll try again automatically." : "", "", "Your workspace pauses in 3 days."]),
        W === 2 ? "We're here if you need a hand." : W === 1 && L ? "Thanks for being with us." : "",
      ].filter(Boolean).join(" ");
      return `${title}\n${body}`;
    }
    push.preview = (settings, { signal }) => new Promise((resolve, reject) => {
      const t = setTimeout(() => resolve(notification(settings)), 450);
      signal.addEventListener("abort", () => { clearTimeout(t); reject(new DOMException("Aborted", "AbortError")); });
    });
  </script>
</div>

Cultural reference

Interstellar — Christopher Nolan (2014, film). The crew's robot has personality settings expressed as percentages, honesty and humor among them, that people adjust in conversation and hear take effect in its very next line. In the UI, an assistant's or a message's tone traits are dialed as percentages with named zones, and a live sample rewrites itself on every move with the changed words highlighted, so the effect of each setting is heard at once.

API

Attributes

NameTypeDefaultDescription
labelstringToneHeading of the component; also its accessible name (role="group").
descriptionstringMuted line under the heading, wired as the group's aria-describedby.
valueJSONInitial settings, e.g. {"humor":75,"formality":40}. Missing traits use their default; the value property set by the app wins.
debouncenumber (ms)300Delay before calling an async preview() again while the user keeps moving. Sync previews run on every move.
snapnumber (%)2Magnetic distance to the trait's marks while dragging with a pointer (0 disables). Keyboard and typed values are never snapped.
diff"words" | "inline" | "off"wordsHow the sample shows what changed since the last setting: inserted words highlighted, insertions plus struck-through deletions, or nothing. The Highlight changes toggle hides it temporarily.
announce"changes" | "full" | "off"changesWhat the polite live region reads once the sample settles: the sentences that changed, the whole sample, or nothing (notes, presets and copies are still announced).
view"sample" | "prompt" | "json"samplePreview tab shown first; changing the attribute switches tabs.
form-format"json" | "prompt"jsonWhat the form submits: the settings JSON ({"formality":45,…}) or the prompt text.
persiststringlocalStorage key suffix (mv-tone-dial:{persist}) where presets saved by the user are kept.
name / disabledformForm association. disabled (or a disabled fieldset) locks the traits and presets; tabs and Copy keep working.

Properties

NameTypeDescription
traitsArray<{ id, label, value?, zones?, marks?, step?, description? }>Trait definitions. value = default % (50). zones = strings (split evenly) or { label, from?, prompt? } objects; prompt is the guidance used in the Prompt export. marks default to 0/25/50/75/100. Also readable from a <script type="application/json"> child ({ traits, presets, hints }). Without traits, six built-in ones are used (formality, warmth, humor, detail, directness, emoji) with their notes.
preview(settings, ctx) => string | { text } | PromiseBuilds the sample. ctx = { signal (aborted when superseded), traits, zones, previous (settings before this change), changed (trait ids), previousText }. Blank lines separate paragraphs, single newlines break lines. Without it, the sample shows the prompt text.
presetsArray<{ id, label, settings, description? }>Ready-made settings shown as toggle chips; traits missing from settings use their default. Reading it also returns user presets ({ user: true }).
hintsArray<{ when: { traitId: ">=67" | "<34" | [min, max] }, message, traits? } | (settings) => string | { message, traits } | null>Incompatibility notes. Every condition in when must hold; the traits involved get a note icon and aria-describedby to the note.
formatPrompt(settings, { traits, zones, hints }) => stringReplaces the built-in prompt text (Prompt tab, toPrompt(), form-format="prompt").
value{ [traitId]: number }Current settings (0–100 integers). Assigning an object or JSON string applies it without emitting mv-change.
defaults / zones / activeHints / previewText / presetread-onlyTrait defaults, current zone label per trait, notes shown, text of the sample, id of the preset matching exactly (or null).
stringsobjectOverrides for every visible text and announcement (partial object, English defaults).

Methods

NameDescription
set(id, value, { emit? })Sets one trait (clamped), updates the sample and emits mv-input / mv-change unless emit is false.
reset(id?)Resets one trait, or all traits to their defaults (sliders glide back).
applyPreset(id) / savePreset(label) / deletePreset(id)Preset management. savePreset returns the new preset (or null if mv-preset was canceled); only user presets can be deleted.
refreshPreview()Calls preview() again with the current settings (e.g. the source data changed).
toPrompt() / toJSON()System-prompt-style text, and { preset, settings, traits: { id: { label, value, zone } } }.
copy(format?)Copies "sample", "prompt" or "json" (default: the tab shown). Resolves to true on success.

Events

NameDescription
mv-inputOn every move. detail: { settings, trait, value, zone, source: "slider" | "input" | "keyboard" | "reset" | "api" }.
mv-changeWhen a change is committed (pointer released, number entered, key step, preset, reset). detail: { settings, zones, changed (ids), preset, source, hints }.
mv-previewThe sample was rendered with new text. detail: { text, settings, added, removed } (word counts against the previous setting).
mv-preview-errorpreview() threw or rejected. detail: { error, settings }. The previous sample stays, with a Retry button.
mv-hintThe set of notes changed. detail: { hints: [{ message, traits }], settings }.
mv-presetCancelable. detail: { action: "save" | "delete", preset }. preventDefault() refuses the save or deletion (e.g. to store presets on the server instead).
mv-exportCancelable, before copying. detail: { format: "sample" | "prompt" | "json", text }.

Content structure

NameDescription
contextContent shown above the sample (the incoming message being answered, the event that triggers the notification…). Styled by the app.

CSS classes

NameDescription
mv-tone-dial-traitOne trait row. data-trait, data-zone (index), data-modified, data-hint.
mv-tone-dial-range / -num / -segs / -zonesThe native range, its numeric field, the zone segments (--_f = fill 0..1 per segment) and the zone labels (data-active on the current one).
mv-tone-dial-presetPreset chip: aria-pressed when it matches, data-edited when it was applied then tweaked.
mv-tone-dial-sample / -ins / -delThe sample, inserted words (<mark>) and deleted words (<del>, diff="inline").
mv-tone-dial-rootInner layout; data-loading while an async preview is pending, data-error after a failure.

CSS variables

NameDefaultDescription
--mv-tone-dial-fillvar(--mv-accent)Slider fill, thumb ring and loading bar.
--mv-tone-dial-highlightvar(--mv-accent)Tint of changed words in the sample.
--mv-tone-dial-hintvar(--mv-warning)Tint of incompatibility notes.
--mv-tone-dial-thumb-size / --mv-tone-dial-track-size1.125rem / 0.375remSlider geometry.

Accessibility

The component is a labelled group (role="group" named by its heading, described by its description). Every trait is a native <input type="range"> labelled by the trait name, with aria-valuetext such as “Humor 72%, Playful”, so the zone is spoken with the number; arrows step by 1, Shift+arrows by 10, Page Up/Down jump to the next or previous mark, Home/End go to 0/100%, and typing digits on the focused slider sets the value directly (“7”, “5” → 75%, Enter applies at once, Escape cancels). Each trait also has a text field labelled “Humor, percent” (Up/Down step, Enter commits, Escape restores, invalid input is marked aria-invalid) and a reset button named “Reset Humor to 20%”. Zones are never shown by color alone: the active zone label is bold, segments show fill, and the default notch is a shape. Presets are toggle buttons with aria-pressed; a tweaked preset adds the word “edited” to its name; user presets have a named delete button; saving happens in an inline text field (Enter saves, Escape cancels, focus returns to the chip). Notes are real text in a list, and each slider involved points to its note with aria-describedby (plus a visible icon with hidden text). The sample is not a live region itself: a separate polite status region reads only the sentences that changed once the sample settles (about a second after the last move, sooner on commit), never on every tick, and also announces new notes, applied presets and copies. Changed words are marked with <mark> (tint plus underline) and deletions with <del> (strike-through), and the highlight can be switched off with a toggle button (aria-pressed). The Sample / Prompt / JSON views are a tablist with arrow-key navigation, and the sample shows aria-busy while an async preview is pending. Reduced motion (prefers-reduced-motion or data-motion="reduce"): sliders jump instead of gliding, no highlight flash, zone bump or indeterminate bar. Forced colors: segments, marks and notch use system colors, changed words use Mark/MarkText, the pressed preset uses Highlight.