Files
hyperframes/docs/catalog/components/comparison-split.mdx
Ular Kimsanov a06fdfe26e docs(catalog): put each item page's title, description and Docs text in the server HTML (#6711)
* docs(catalog): put each item page's title, description and Docs text in the server HTML

Mintlify renders snippet components in the browser only, so everything inside
CatalogDetail (title, standfirst, the Docs tab) was missing from the HTML search
engines read: 448 catalog pages served Googlebot no body text. The generator now
writes the H1 and standfirst as page markup above the component and the Docs tab
as page markup after it, shown with that tab (docs/custom.css). The component no
longer draws its own title. Pages patched structurally only; registry drift on 14
pages is left for a regeneration of its own.

* docs(catalog): keep the title's top spacing the component gave it

* fix(docs): catalog titles and descriptions keep markdown markers literal

MDX reads text inside the static <h1> and <p> as inline markdown, so @_blue_sweater_
rendered as italic blue_sweater. mdxText now writes backslash, backtick, * _ [ ] ~ as
character references too, with a unit test; the four affected pages are re-escaped.
2026-10-30 15:48:25 +00:00

536 lines
20 KiB
Plaintext

---
title: "Comparison Split"
description: "Two full-bleed panels compare before and after states as a persistent divider wipes the vivid after layer over the muted before layer and rests at a configurable split."
mode: "frame"
---
import { CatalogDetail, CatalogSlot } from "/snippets/catalog-detail.jsx";
import { InstallCommand } from "/snippets/install-command.jsx";
<div className="hf-ve-static-head not-prose">
<h1>Comparison Split</h1>
<p>Two full-bleed panels compare before and after states as a persistent divider wipes the vivid after layer over the muted before layer and rests at a configurable split.</p>
</div>
<CatalogDetail
previewSrc="/public/catalog/components/comparison-split.json"
compositionId="comparison-split"
compositionSrc="compositions/components/comparison-split.html"
title="Comparison Split"
variables={[{"id":"split","type":"number","role":"layout","label":"Split position","description":"Target resting position of the comparison divider.","default":50,"min":0,"max":100,"step":1,"unit":"%"},{"id":"orientation","type":"enum","role":"layout","label":"Orientation","description":"Axis and direction used by the comparison wipe.","default":"horizontal","options":[{"value":"horizontal","label":"Horizontal"},{"value":"vertical","label":"Vertical"}]},{"id":"labelA","type":"string","role":"content","label":"Before label","description":"Caption shown on the base before panel.","default":"Before"},{"id":"labelB","type":"string","role":"content","label":"After label","description":"Caption shown on the revealed after panel.","default":"After"}]}
meta={{"category":"Effects","badge":"Stable","codeLines":449}}
attribution={{"path":"registry/components/comparison-split","tags":["prop","ui-props","compare","holdable","before-after","wipe"]}}
hasCode
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/comparison-split/comparison-split.html"
>
<CatalogSlot slot="code">
```html comparison-split.html
<!doctype html>
<!--
comparison-split: HyperFrames video primitive (ui-props / holdable / compare)
Concept: two full-bleed treatments share one frame. The muted before panel
stays fully visible as the base, while the vivid after panel wipes over it
from the leading edge. A persistent divider marks the shared clip boundary.
One mechanic, one job: compare two visual states.
Compiled-from evidence: fixture + blueprint (re-filed from prove), from the
video-primitives compare shelf.
Use when: a scene needs a direct before-and-after comparison, a redesign
reveal, or a visual treatment contrast that must remain readable during a
long hold.
Variables (declared in data-composition-variables below):
- split (number, default 50, range 0 to 100%): target resting position.
- orientation (horizontal | vertical, default horizontal): horizontal uses
a vertical divider traveling left to right, vertical uses a horizontal
divider traveling top to bottom.
- labelA (string, default "Before"): caption on the base panel.
- labelB (string, default "After"): caption on the revealed panel.
Envelope (fixed IN/OUT, elastic HOLD only, never gsap.timeScale()):
IN_BASE = 1.40s stage settles, then the divider wipes from 0% to split
HOLD = elastic = max(0, D - (IN_BASE + OUT_BASE)); deliberately still
so the target comparison remains easy to inspect
OUT_BASE = 0.50s release fade
If D < IN_BASE + OUT_BASE, IN and OUT scale down together so IN + OUT == D
and HOLD == 0.
Sync point: wipe-land is fixed at WIPE_END_BASE = 1.30s inside IN. It scales
proportionally only when the full envelope is shorter than 1.90s, and never
moves into elastic HOLD.
Sound cue: a soft wipe landing hook fires at wipe-land. This primitive never
plays audio. It dispatches a bubbling `hf:sfx` CustomEvent with id
"wipe-land-soft", so scene-level audio mixing owns playback.
Mount contract: this file is a MOUNTABLE SUB-COMPOSITION. A host loads it via
data-composition-src, and the runtime clones only <template> contents. All
live styles, markup, GSAP, and timeline registration therefore live inside
<template>. #root is styled by id, fills the host with inset:0, establishes
the cqw/cqh container, and has no data-width or data-height. The composition
id is the hardcoded literal "comparison-split" because mount flattening
strips data-composition-id from the live root. Variables come from
window.__hyperframes.getVariables(), which merges declared defaults with
per-instance host overrides.
-->
<html
lang="en"
data-composition-variables='[
{ "id": "split", "type": "number", "role": "layout", "label": "Split position", "description": "Target resting position of the comparison divider.", "default": 50, "min": 0, "max": 100, "step": 1, "unit": "%" },
{ "id": "orientation", "type": "enum", "role": "layout", "label": "Orientation", "description": "Axis and direction used by the comparison wipe.", "default": "horizontal", "options": [{ "value": "horizontal", "label": "Horizontal" }, { "value": "vertical", "label": "Vertical" }] },
{ "id": "labelA", "type": "string", "role": "content", "label": "Before label", "description": "Caption shown on the base before panel.", "default": "Before" },
{ "id": "labelB", "type": "string", "role": "content", "label": "After label", "description": "Caption shown on the revealed after panel.", "default": "After" }
]'
>
<head>
<!-- The mount runtime reads variables from <html>, then discards everything
outside <template>, including this head. -->
</head>
<body>
<template>
<div id="root" data-composition-id="comparison-split" data-duration="4" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Root fills the host-owned box. Every internal measurement uses
cqw/cqh, and every painted color comes from a theme token. */
#root {
position: absolute;
inset: 0;
container-type: size;
isolation: isolate;
overflow: hidden;
background: var(--bg, #07111f);
color: var(--fg, #f8fafc);
font-family: var(--font-body, Inter, system-ui, sans-serif);
}
.cs-clip,
.cs-stage,
.cs-panel {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.cs-clip {
overflow: hidden;
}
.cs-stage {
--cs-split: 0;
opacity: 0;
}
.cs-before {
background:
linear-gradient(
color-mix(in srgb, var(--border, #334155) 38%, transparent) 0.12cqw,
transparent 0.12cqw
)
0 0 / 5cqw 5cqw,
linear-gradient(
90deg,
color-mix(in srgb, var(--border, #334155) 38%, transparent) 0.12cqw,
transparent 0.12cqw
)
0 0 / 5cqw 5cqw,
var(--surface, #172033);
}
.cs-after {
z-index: 2;
clip-path: inset(0 calc((100 - var(--cs-split)) * 1%) 0 0);
background: color-mix(in srgb, var(--fg, #f8fafc) 18%, var(--surface, #172033));
}
.cs-window {
position: absolute;
left: 14cqw;
top: 18cqh;
width: 72cqw;
height: 64cqh;
overflow: hidden;
border-radius: min(3cqw, 5cqh);
border: 0.16cqw solid var(--border, #334155);
background: color-mix(in srgb, var(--surface, #172033) 90%, var(--muted, #94a3b8));
box-shadow: 0 2.2cqh 6cqw color-mix(in srgb, var(--bg, #07111f) 50%, transparent);
}
.cs-after .cs-window {
border-color: color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
background: color-mix(in srgb, var(--fg, #f8fafc) 8%, var(--surface, #172033));
box-shadow: 0 2.2cqh 7cqw rgba(0, 0, 0, 0.18);
}
.cs-toolbar {
display: flex;
align-items: center;
gap: var(--space-1, 1.2cqw);
height: 12cqh;
padding: 0 var(--space-2, 2.4cqw);
border-bottom: 0.14cqw solid var(--border, #334155);
}
.cs-dot {
width: min(2.2cqw, 3.2cqh);
aspect-ratio: 1;
border-radius: 50%;
background: var(--muted, #94a3b8);
opacity: 0.55;
}
.cs-after .cs-dot {
background: var(--accent, #f8fafc);
opacity: 1;
}
.cs-content {
position: relative;
height: 52cqh;
}
.cs-copy {
position: absolute;
left: 7cqw;
top: 9cqh;
width: 31cqw;
}
.cs-line {
height: 2.3cqh;
margin-bottom: var(--space-2, 2.4cqh);
border-radius: 2cqh;
background: var(--muted, #94a3b8);
opacity: 0.48;
}
.cs-line:nth-child(1) {
width: 94%;
}
.cs-line:nth-child(2) {
width: 72%;
}
.cs-line:nth-child(3) {
width: 48%;
}
.cs-after .cs-line {
background: color-mix(in srgb, var(--fg, #f8fafc) 72%, transparent);
opacity: 1;
}
.cs-visual {
position: absolute;
right: 7cqw;
top: 7cqh;
width: min(22cqw, 32cqh);
aspect-ratio: 1;
border-radius: 36% 64% 58% 42%;
border: 0.3cqw solid var(--border, #334155);
background: var(--muted, #94a3b8);
opacity: 0.5;
}
.cs-after .cs-visual {
border-color: color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
background: color-mix(in srgb, var(--fg, #f8fafc) 72%, transparent);
box-shadow: 0 0 5cqw rgba(0, 0, 0, 0.18);
opacity: 1;
}
.cs-label {
position: absolute;
z-index: 4;
top: var(--space-3, 4cqh);
padding: var(--space-1, 1.2cqh) var(--space-2, 2.2cqw);
border: 0.14cqw solid var(--border, #334155);
border-radius: 999cqw;
background: var(--surface, #172033);
color: var(--fg, #f8fafc);
font-family: var(--font-display, Inter, system-ui, sans-serif);
font-size: min(3cqw, 4.2cqh);
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
white-space: nowrap;
}
.cs-before .cs-label {
right: var(--space-3, 4cqw);
color: var(--muted, #94a3b8);
}
.cs-after .cs-label {
left: var(--space-3, 4cqw);
border-color: color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
background: color-mix(in srgb, var(--fg, #f8fafc) 8%, var(--surface, #172033));
}
.cs-divider {
position: absolute;
z-index: 5;
top: 0;
bottom: 0;
left: calc(var(--cs-split) * 1%);
width: 0.45cqw;
transform: translateX(-50%);
background: var(--fg, #f8fafc);
box-shadow:
0 0 1.4cqw color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent),
0 0 3cqw rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.cs-divider::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: min(5cqw, 7cqh);
aspect-ratio: 1;
transform: translate(-50%, -50%);
border: 0.3cqw solid var(--bg, #07111f);
border-radius: 50%;
background: var(--fg, #f8fafc);
box-shadow: 0 0 2.2cqw color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
}
/* INVARIANT: vertical changes only the axis mapping. The after clip
and divider still read the same --cs-split value. */
#root[data-orientation="vertical"] .cs-after {
clip-path: inset(0 0 calc((100 - var(--cs-split)) * 1%) 0);
}
#root[data-orientation="vertical"] .cs-before .cs-label {
top: auto;
right: var(--space-3, 4cqw);
bottom: var(--space-3, 4cqh);
}
#root[data-orientation="vertical"] .cs-divider {
top: calc(var(--cs-split) * 1%);
right: 0;
bottom: auto;
left: 0;
width: 100%;
height: 0.45cqh;
transform: translateY(-50%);
}
</style>
<div
id="comparison-split-clip"
class="cs-clip clip"
data-start="0"
data-duration="4"
data-track-index="0"
>
<div class="cs-stage">
<section class="cs-panel cs-before" aria-label="Before panel">
<div class="cs-label"></div>
<div class="cs-window">
<div class="cs-toolbar">
<span class="cs-dot"></span>
<span class="cs-dot"></span>
<span class="cs-dot"></span>
</div>
<div class="cs-content">
<div class="cs-copy">
<div class="cs-line"></div>
<div class="cs-line"></div>
<div class="cs-line"></div>
</div>
<div class="cs-visual"></div>
</div>
</div>
</section>
<section class="cs-panel cs-after" aria-label="After panel">
<div class="cs-label"></div>
<div class="cs-window">
<div class="cs-toolbar">
<span class="cs-dot"></span>
<span class="cs-dot"></span>
<span class="cs-dot"></span>
</div>
<div class="cs-content">
<div class="cs-copy">
<div class="cs-line"></div>
<div class="cs-line"></div>
<div class="cs-line"></div>
</div>
<div class="cs-visual"></div>
</div>
</div>
</section>
<div class="cs-divider" aria-hidden="true"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
// Hardcoded because mount flattening strips data-composition-id
// from the live root before this timeline registers.
var compositionId = "comparison-split";
var stage = root.querySelector(".cs-stage");
var labelAEl = root.querySelector(".cs-before .cs-label");
var labelBEl = root.querySelector(".cs-after .cs-label");
// EDIT ZONE: declarations above and registry metadata change
// together. This API owns defaults plus per-instance overrides.
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var rawSplit = vars.split == null ? 50 : Number(vars.split);
// INVARIANT: split always clamps into the declared 0 to 100 range.
var splitPct = Number.isFinite(rawSplit) ? Math.max(0, Math.min(100, rawSplit)) : 50;
// INVARIANT: only horizontal or vertical reaches the renderer.
var orientation = vars.orientation === "vertical" ? "vertical" : "horizontal";
var labelAText = vars.labelA == null ? "" : String(vars.labelA);
var labelBText = vars.labelB == null ? "" : String(vars.labelB);
root.setAttribute("data-orientation", orientation);
labelAEl.textContent = labelAText;
labelBEl.textContent = labelBText;
// RETIME RANGE: fixed IN and OUT scale together only when D is too
// short. HOLD is the sole elastic phase. Never use timeScale().
var IN_BASE = 1.4;
var OUT_BASE = 0.5;
var STAGE_IN_BASE = 0.32;
var WIPE_AT_BASE = 0.25;
var WIPE_DURATION_BASE = 1.05;
var WIPE_END_BASE = WIPE_AT_BASE + WIPE_DURATION_BASE;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
var totalBase = IN_BASE + OUT_BASE;
var scale = duration < totalBase ? duration / totalBase : 1;
var IN = IN_BASE * scale;
var OUT = OUT_BASE * scale;
var STAGE_IN = STAGE_IN_BASE * scale;
var WIPE_AT = WIPE_AT_BASE * scale;
var WIPE_DURATION = WIPE_DURATION_BASE * scale;
var WIPE_END = WIPE_END_BASE * scale;
var HOLD = Math.max(0, duration - (IN + OUT));
var OUT_START = IN + HOLD;
function fireSfx(id, t) {
root.dispatchEvent(
new CustomEvent("hf:sfx", { detail: { id: id, t: t }, bubbles: true }),
);
}
// Explicit both-endpoints state makes tl.seek(0) deterministic.
// --cs-split is the only owner of both clip and divider progress.
gsap.set(stage, { opacity: 0, "--cs-split": 0 });
var tl = gsap.timeline({ paused: true });
// IN: stage settle, then one shared wipe value travels to split.
tl.to(stage, { opacity: 1, duration: STAGE_IN, ease: "power2.out" }, 0);
tl.to(
stage,
{ "--cs-split": splitPct, duration: WIPE_DURATION, ease: "sine.inOut" },
WIPE_AT,
);
tl.call(
function () {
fireSfx("wipe-land-soft", WIPE_END);
},
[],
WIPE_END,
);
// HOLD: deliberately steady at the target split.
// OUT: release fade.
tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.out" }, OUT_START);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</CatalogSlot>
<CatalogSlot slot="install">
<InstallCommand command="npx hyperframes add comparison-split" item="comparison-split" />
That writes one file: `compositions/components/comparison-split.html`.
</CatalogSlot>
</CatalogDetail>
<div data-static-slot="docs" className="prose prose-gray dark:prose-invert">
## Paste it into your composition
Open `compositions/components/comparison-split.html` and copy what is inside into your own composition.
A component has no size or duration of its own. It takes both from the composition
you paste it into.
## Variables
Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:
| Variable | Default | Accepts | What it does |
| --- | --- | --- | --- |
| `split` | `50` | 0% to 100%, step 1% | Target resting position of the comparison divider. |
| `orientation` | `horizontal` | `horizontal`, `vertical` | Axis and direction used by the comparison wipe. |
| `labelA` | `Before` | string | Caption shown on the base before panel. |
| `labelB` | `After` | string | Caption shown on the revealed after panel. |
Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
```html wrap
<div
data-composition-id="comparison-split"
data-composition-src="compositions/components/comparison-split.html"
data-variable-values='{"split":50,"orientation":"horizontal","labelA":"Before","labelB":"After"}'
></div>
```
{/* hf:generated-footer */}
Tagged `prop` `ui-props` `compare` `holdable` `before-after` `wipe`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks)
- [Put blocks together into one video](/concepts/compositions)
</div>