Exclusivebeta

Variant Tree — <mv-variant-tree>

A branching variant explorer for any element driven by CSS custom properties (or any state through callbacks): “Generate 4 variants” sprouts mutated children of the applied variant (a Spread slider sets how bold the mutations are), shown as a tidy tree of live thumbnails — scaled, inert clones of the target, so it works with any component without screenshots. Keep favorites, discard dead ends into a “3 variants discarded” stub, pin up to four variants in a real-size Compare strip with labeled diffs (“hue 212 → 18”), cross two of them param by param, and check out any node: the target tweens to it. Horizontal tree on wide containers, indented outline on narrow ones, seedable, serializable, one-click CSS export.

CategoryData display
TypeWeb Component (<mv-variant-tree>)
Statusbeta
Keywordsexclusive, variants, exploration, branching, tree, generative, design-tool, playground, compare, crossover, mutation, css-variables, theming, history, curation

When to use

Avoid when

Install

node scripts/add.mjs variant-tree --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["variant-tree"], 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, core/observe.js, components/variant-tree/variant-tree.js, components/variant-tree/variant-tree.css.

Usage

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

<div id="vt-demo" style="width:min(100%,72rem);margin-inline:auto">
  <style>
    #vt-demo .vt-layout { display:grid; gap:1.25rem; align-items:start }
    @media (min-width:60rem) { #vt-demo .vt-layout { grid-template-columns:minmax(19rem,22rem) minmax(0,1fr) } #vt-demo .vt-stage { position:sticky; top:1rem } }
    #vt-demo .vt-stage { display:grid; gap:.75rem; justify-items:center; padding:1.5rem 1rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl);
      background:radial-gradient(circle at 1px 1px, color-mix(in oklab, var(--mv-fg) 10%, transparent) 1px, transparent 1.5px) 0 0 / 18px 18px, var(--mv-bg-subtle) }
    #vt-demo .vt-stage-label { justify-self:stretch; display:flex; justify-content:space-between; gap:1rem; color:var(--mv-fg-muted); font-size:.75rem }
    #vt-demo .vt-stage-label strong { color:var(--mv-fg); font-weight:600 }
    #vt-demo .vt-hint { margin:.5rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center; line-height:1.9 }

    /* The explored target: a promo card driven entirely by four custom properties. */
    #vt-demo .vt-promo {
      --hue: 262; --radius: 18px; --scale: 1; --weight: 650;
      --mv-accent: light-dark(oklch(0.56 var(--mv-accent-c) var(--hue)), oklch(0.68 var(--mv-accent-c) var(--hue)));
      --mv-accent-hover: color-mix(in oklch, var(--mv-accent), var(--mv-fg) 12%);
      --mv-accent-active: color-mix(in oklch, var(--mv-accent), var(--mv-fg) 22%);
      --mv-accent-subtle: color-mix(in oklch, var(--mv-accent) 14%, transparent);
      --mv-accent-fg: light-dark(oklch(0.45 var(--mv-accent-c) var(--hue)), oklch(0.82 calc(var(--mv-accent-c) * .8) var(--hue)));
      --mv-button-radius: calc(var(--radius) * .6);
      --mv-badge-radius: calc(var(--radius) * .5);
      box-sizing:border-box; width:17rem; display:grid; gap:.625rem; padding:.875rem; border-radius:var(--radius);
      background:linear-gradient(170deg, color-mix(in oklch, var(--mv-accent) 9%, var(--mv-surface-raised)), var(--mv-surface-raised) 55%);
      border:1px solid color-mix(in oklch, var(--mv-accent) 24%, var(--mv-border)); box-shadow:var(--mv-shadow-lg);
      color:var(--mv-fg); font-family:var(--mv-font-sans); text-align:left;
    }
    #vt-demo .vt-visual { position:relative; display:grid; place-items:center; height:7.25rem; border-radius:calc(var(--radius) * .72); overflow:hidden; color:var(--mv-fg-on-accent);
      background:radial-gradient(circle at 28% 22%, color-mix(in oklch, var(--mv-accent) 40%, var(--mv-fg-on-accent)), transparent 58%), linear-gradient(150deg, var(--mv-accent), color-mix(in oklch, var(--mv-accent), var(--mv-gray-950) 42%)) }
    #vt-demo .vt-visual svg { width:4.25rem; height:4.25rem; filter:drop-shadow(0 6px 10px color-mix(in oklch, var(--mv-gray-950) 35%, transparent)) }
    #vt-demo .vt-visual .mv-badge { position:absolute; top:.625rem; left:.625rem; box-shadow:0 0 0 2px color-mix(in oklch, var(--mv-fg-on-accent) 35%, transparent) }
    #vt-demo .vt-kicker { margin:.125rem 0 0; color:var(--mv-accent-fg); font-size:.6875rem; font-weight:600; letter-spacing:.08em; text-transform:uppercase }
    #vt-demo .vt-title { margin:0; font-size:calc(1.25rem * var(--scale)); font-weight:var(--weight); line-height:1.15; letter-spacing:-.015em }
    #vt-demo .vt-text { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
    #vt-demo .vt-price { display:flex; align-items:baseline; gap:.5rem; margin:.125rem 0 .125rem }
    #vt-demo .vt-price strong { font-size:calc(1.5rem * var(--scale)); font-weight:var(--weight); letter-spacing:-.02em; font-variant-numeric:tabular-nums }
    #vt-demo .vt-price s { color:var(--mv-fg-subtle); font-size:.875rem }
    #vt-demo .vt-price span { margin-inline-start:auto; color:var(--mv-success); font-size:.75rem; font-weight:500 }
    #vt-demo .vt-promo .mv-button { width:100% }
  </style>

  <div class="vt-layout">
    <div class="vt-stage">
      <div class="vt-stage-label"><strong>Live preview</strong><span>Follows the applied variant</span></div>
      <article class="vt-promo" id="vt-promo" data-vary="--hue: 0..360; --radius: 4..28px; --scale: .9..1.25 as type scale; --weight: 500|650|800">
        <div class="vt-visual">
          <span class="mv-badge" data-appearance="solid" data-variant="danger">−30%</span>
          <svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" aria-hidden="true"><path d="M12 40v-8a20 20 0 0 1 40 0v8"/><rect x="8" y="38" width="12" height="18" rx="5" fill="currentColor" stroke="none"/><rect x="44" y="38" width="12" height="18" rx="5" fill="currentColor" stroke="none"/></svg>
        </div>
        <p class="vt-kicker">Back-to-school deal</p>
        <h4 class="vt-title">Nomad ANC Headphones</h4>
        <p class="vt-text">Active noise cancelling, 40-hour battery, folds flat for travel.</p>
        <p class="vt-price"><strong>$139</strong><s>$199</s><span>Free shipping</span></p>
        <button class="mv-button" type="button">Get the deal</button>
      </article>
    </div>

    <div>
      <mv-variant-tree id="vt-tree" for="vt-promo" count="4" seed="180" spread="0.45"></mv-variant-tree>
      <p class="vt-hint">Click a thumbnail to apply it · double-click to branch from it · in the tree:
        <kbd class="mv-kbd">↑</kbd> <kbd class="mv-kbd">↓</kbd> browse, <kbd class="mv-kbd">F</kbd> keep, <kbd class="mv-kbd">P</kbd> compare, <kbd class="mv-kbd">G</kbd> generate, <kbd class="mv-kbd">Del</kbd> discard</p>
    </div>
  </div>

  <script type="module">
    const vt = document.getElementById("vt-tree");
    await customElements.whenDefined("mv-variant-tree");
    // A past exploration session, so the tree tells a story on first sight:
    // four first tries, three dead ends discarded, one branch explored further.
    const quiet = { animate: false };
    const [a, b, c, d] = vt.generate(4, quiet);
    [a, c, d].forEach((id) => vt.prune(id));
    const kids = vt.generate(4, { ...quiet, from: b });
    vt.favorite(kids[1]);
    vt.pin(kids[1]);
    vt.pin(kids[3]);
    vt.checkout(kids[1], quiet);

    vt.addEventListener("mv-checkout", (e) => console.info("mv-checkout", e.detail.id, e.detail.params));
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
forstringId of the element to explore (looked up in the same root; waited for if rendered later). Without it, the first child element is the target.
paramsstringExplorable parameters, same syntax as data-vary on the target (which is used when this attribute is absent).
data-varystringOn the target: “--hue: 0..360; --radius: 0..28px step 2 as corners; --weight: 400|600|800”. Numeric ranges (unit taken from the bounds, optional step, wrap for circular values — a 0..360 hue wraps by default) or discrete sets separated by |. “as <label>” names the parameter in the UI; otherwise the last segment of the property name is used. Plain CSS properties (opacity…) work too.
countnumber4Children created per generation (1–12).
spreadnumber (0–1)0.4Mutation strength, mirrored by the Spread slider: subtle nudges and neighbor values when low, far jumps when high. At least one parameter always changes, siblings never duplicate each other.
seednumberSeeds the random generator: the same seed and actions give the same tree (demos, tests, shareable explorations).
labelstringExplore variantsPanel title.
max-previewsnumber16Maximum live clones in the tree. Priority: applied path, pinned, kept, then newest; the others show a cheap bar glyph of their parameters.
layoutauto | horizontal | verticalautoTidy horizontal tree, or indented outline. auto switches to the outline under 520px of component width (ResizeObserver).

Properties

NameTypeDescription
paramsParam[] | stringParsed parameters ({ name, label, type: "range", min, max, unit, step, wrap } | { name, label, type: "set", values }). Assign an array or a string to redefine them (restarts the tree).
nodes{ id, parent, mate, label, params, favorite, pinned, pruned, current }[]Serializable tree. Assign a saved array to restore an exploration (can be set before the element is defined).
current{ id, params, css }The applied variant, with its CSS declarations ready to paste (read-only).
mutate(state, rng, spread) => stateCustom mutation for arbitrary state (return a new object or mutate the copy). rng() is the seeded generator.
apply(state, el) => voidCustom rendering: called on the target and on every preview clone. Replaces the default custom-property writes (no tween).

Methods

NameDescription
generate(n?, { from?, animate? })Creates n mutated children of the applied node (or from) and returns their ids. animate: false skips the sprout animation (seeding).
checkout(id, { animate? })Applies a node to the target; numeric parameters tween (hue the short way around), discrete ones switch.
favorite(id?, force?)Toggles the star (defaults to the applied node).
pin(id?, force?)Toggles a node in the Compare strip (4 at most, the oldest makes room).
combine(a, b, picks?)Creates and applies a hybrid child of a: for each differing parameter, the value of a or b (picks: { name: "a" | "b" }, random but mixed when omitted). Returns its id, or null if it would equal a parent.
prune(id?) / restore(id)Folds a node and its descendants into a stub / unfolds a node or every pruned child of id.
reset(state?)Starts over from state (default: the target's current computed values).
refresh()Re-clones the previews (content changes inside the target are also picked up automatically).

Events

NameDescription
mv-generateNew children. detail: { parent, ids, params[] }.
mv-checkoutA node was applied. detail: { id, params }.
mv-combineA hybrid was created. detail: { id, a, b, params }.
mv-favoritedetail: { id, favorite }.
mv-prunedetail: { id, count }.

Content structure

NameDescription
(content)Optional: the target itself, when for is not used.

CSS classes

NameDescription
mv-variant-tree-panel / -head / -barGenerated panel, header (title, status, Copy variables) and toolbar acting on the applied variant.
mv-variant-tree-view / -node / -thumb / -stub / -edgeScrollable tree canvas, tiles (role treeitem) with their live thumbnail, discarded-variants stub and SVG edges (data-path on the applied path, data-kind="mate" for a hybrid's second parent).
mv-variant-tree-combine / -compare / -chipCrossover panel, Compare strip and diff chips.

CSS variables

NameDefaultDescription
--mv-variant-tree-accentvar(--mv-accent)Applied node, path and primary actions.
--mv-variant-tree-favoritevar(--mv-warning)Star of kept variants.
--mv-variant-tree-matevar(--mv-info)Side B of a crossover and the hybrid's second edge.
--mv-variant-tree-thumb-bgvar(--mv-bg-subtle)Background behind the clones.
--mv-variant-tree-bgvar(--mv-surface)Panel background.
--mv-variant-tree-max-height42remHeight of the tree view before it scrolls.

Accessibility

The tree is a real role="tree": each tile is a treeitem with aria-level, aria-setsize, aria-posinset, aria-expanded and aria-selected (the applied variant), named by its parameter summary (“Variant 3 · hue 18, radius 12px · kept · in comparison, slot 1”). Roving tabindex: Up/Down/Home/End browse in reading order, Right/Left expand, collapse or move to child/parent, Enter or Space applies, F keeps, P compares, G generates from the focused node, Delete discards (focus moves to the stub, which Enter restores). Thumbnails are aria-hidden and inert clones (ids, names and labels stripped, never focusable). Applying, generating, keeping, comparing, combining and copying are announced in a polite live region; the Spread slider has an aria-valuetext (“Moderate, 45%”); unavailable actions stay focusable with aria-disabled and explain why when used. Diff chips read “hue 212 becomes 18”. Focus rings everywhere. Reduced motion (OS or data-motion="reduce"): no sprout, bloom, edge growth or glide, and the target switches instantly instead of tweening.