> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Comparison Split

> 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.

# Comparison Split

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.

**Comparison Split**

```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>
```

Copy npx hyperframes add comparison-split to the clipboard

That writes one file: `compositions/components/comparison-split.html`.

## 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.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)
