独家
Variant Tree <mv-variant-tree>
适用于任何由 CSS 自定义属性驱动的元素(或通过回调驱动的任何状态)的分支变体探索器:“Generate 4 variants”会基于当前应用的变体生成突变子项(Spread 滑块设置突变的大胆程度),以整齐的实时缩略图树展示:目标元素经过缩放、不可交互的克隆,因此无需截图即可适用于任何组件。
工作原理
一个分支式变体浏览器,适用于任何由 CSS 自定义属性驱动的元素(或通过回调驱动的任何状态):“Generate 4 variants” 会从已应用的变体中生成变异的子变体(Spread 滑块设定变异的大胆程度),以整齐的实时缩略图树形展示:这些缩略图是目标元素经缩放的惰性克隆,因此无需截图即可适用于任何组件。收藏喜欢的变体,把死胡同丢进 “3 variants discarded” 折叠项,在真实尺寸的 Compare 条中固定最多四个变体并显示带标注的差异(“hue 212 → 18”),将其中两个逐参数交叉组合,还可以检出任意节点:目标会补间过渡到该节点。宽容器上为水平树,窄容器上为缩进大纲,支持随机种子、可序列化,一键导出 CSS。
| 分类 | 数据展示 |
|---|---|
| 类型 | Web Component(<mv-variant-tree>) |
| 状态 | 稳定版 |
| Keywords | exclusive, variants, exploration, branching, tree, generative, design-tool, playground, compare, crossover, mutation, css-variables, theming, history, curation |
When to use
- Designers explore variations of a component by mutating CSS custom properties and branching from favorites
- Several variants must be compared side by side at real size with labeled parameter diffs
- An exploration must be reproducible from a seed and exported as CSS
Avoid when
- Users only need to pick one color value → use Color Picker instead
- End users of a consumer product configure a setting; this is a design and exploration tool
- The need is to record and undo a user's form edits, not to generate and explore variants → use Rewind instead
安装
node scripts/add.mjs variant-tree --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["variant-tree"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):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。
用法
标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:
<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">Aurelo 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
| Name | 类型 | Default | Description |
|---|---|---|---|
for | string | Id 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. | |
params | string | Explorable parameters, same syntax as data-vary on the target (which is used when this attribute is absent). | |
data-vary | string | On 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. | |
count | number | 4 | Children created per generation (1-12). |
spread | number (0-1) | 0.4 | Mutation 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. |
seed | number | Seeds the random generator: the same seed and actions give the same tree (demos, tests, shareable explorations). | |
label | string | Explore variants | Panel title. |
max-previews | number | 16 | Maximum live clones in the tree. Priority: applied path, pinned, kept, then newest; the others show a cheap bar glyph of their parameters. |
layout | auto | horizontal | vertical | auto | Tidy horizontal tree, or indented outline. auto switches to the outline under 520px of component width (ResizeObserver). |
Properties
| Name | 类型 | Description |
|---|---|---|
params | Param[] | string | Parsed 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) => state | Custom mutation for arbitrary state (return a new object or mutate the copy). rng() is the seeded generator. |
apply | (state, el) => void | Custom rendering: called on the target and on every preview clone. Replaces the default custom-property writes (no tween). |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-generate | New children. detail: { parent, ids, params[] }. |
mv-checkout | A node was applied. detail: { id, params }. |
mv-combine | A hybrid was created. detail: { id, a, b, params }. |
mv-favorite | detail: { id, favorite }. |
mv-prune | detail: { id, count }. |
Content structure
| Name | Description |
|---|---|
(content) | Optional: the target itself, when for is not used. |
CSS classes
| Name | Description |
|---|---|
mv-variant-tree-panel / -head / -bar | Generated panel, header (title, status, Copy variables) and toolbar acting on the applied variant. |
mv-variant-tree-view / -node / -thumb / -stub / -edge | Scrollable 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 / -chip | Crossover panel, Compare strip and diff chips. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-variant-tree-accent | var(--mv-accent) | Applied node, path and primary actions. |
--mv-variant-tree-favorite | var(--mv-warning) | Star of kept variants. |
--mv-variant-tree-mate | var(--mv-info) | Side B of a crossover and the hybrid's second edge. |
--mv-variant-tree-thumb-bg | var(--mv-bg-subtle) | Background behind the clones. |
--mv-variant-tree-bg | var(--mv-surface) | Panel background. |
--mv-variant-tree-max-height | 42rem | Height 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 (Tab enters the tree on the applied variant): 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; Enter on it restores and focuses the first variant back). 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. Forced colors: the applied node, its path, pressed Keep / Compare and the Spread slider switch to system colors and outlines. Known limits: preview clones lose their ids, names and ARIA references, so style the target by class rather than by id; custom elements inside the target run in every live thumbnail (capped by max-previews); a custom apply switches without tween; texts, announcements and numbers are English (en-US) with no strings or locale override yet.