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

# Toggle Flip

> An oversized UI toggle switch that flips with real physicality: thumb overshoot, track color crossfade, and a soft press-compress before release. The reference prop of the ui-props family.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="toggle-flip preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/toggle-flip.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/toggle-flip.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add toggle-flip" />

That writes one file: `compositions/components/toggle-flip.html`.

## Paste it into your composition

Open `compositions/components/toggle-flip.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                                             |
| ----------- | ----------- | ------------------- | -------------------------------------------------------- |
| `direction` | `on`        | `on`, `off`         | Target end state the flip lands on.                      |
| `label`     | `Auto-save` | string              | Optional caption under the toggle. Blank hides the line. |
| `size`      | `40`        | 20% to 70%, step 1% | Toggle width as a percent of the host box width.         |

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 theme={null}
<div
  data-composition-id="toggle-flip"
  data-composition-src="compositions/components/toggle-flip.html"
  data-variable-values='{"direction":"on","label":"Auto-save","size":40}'
></div>
```

## Source

<Accordion title={`toggle-flip.html`}>
  ```html theme={null}
  <!doctype html>
  <!--
    toggle-flip -- HyperFrames video primitive (ui-props / interaction / demonstrate)

    Concept: an oversized UI toggle switch that flips with real physicality --
    thumb travel with a slight overshoot, a track color crossfade from
    var(--surface) to var(--brand), and a soft press-compress right before the
    knob releases into its new state. One mechanic, one job: demonstrating a
    state change. This is the reference prop of the ui-props family -- the
    thing a cursor or touch actor "operates" in a composed scene, and it also
    has to read alone.

    Compiled-from evidence: fixture; the reference prop (video-primitives
    catalog, ui-props / interaction / demonstrate shelf).

    Use when: showing a feature, setting, or permission turning on/off, or any
    binary state change a pointer actor (oversized-cursor, touch-indicator)
    operates on. Composes with those pointer primitives, which anchor to this
    file's [data-anchor="toggle-flip"] button.

    Variables (declared in data-composition-variables below):
      - direction ("on" | "off", default "on"): the flip's TARGET state.
        "on"  -> starts OFF (surface track), flips TO on (brand track).
        "off" -> starts ON  (brand track),   flips TO off (surface track).
      - label (string, default "Auto-save"): optional caption under the
        toggle. Empty string hides the caption line entirely.
      - size (number, percent of container width, default 40): toggle width
        as a percentage of the host box's own width (cqw-driven, so it stays
        proportional no matter what box a host composition gives it).

    Envelope (fixed IN/OUT, elastic HOLD only -- never gsap.timeScale()):
      IN_BASE  = 0.90s  stage settles in, anticipation press, then the flip
      HOLD     = elastic = max(0, D - (IN_BASE + OUT_BASE)); ambient glow
                 breathes gently to prove the frame is alive, or sits calm at
                 HOLD = 0 for short durations
      OUT_BASE = 0.50s  release fade
      If D < IN_BASE + OUT_BASE, IN and OUT scale down together (never
      time-scaled) so IN + OUT == D and HOLD == 0.

    Sync point (fixed offset into IN, never inside the elastic HOLD): the flip
    lands at FLIP_AT = 0.5s into an unscaled IN (scales proportionally with IN
    when the envelope is compressed -- see RETIME RANGE below).

    Sound cue: a soft click/tock foley fires at the flip sync point. The
    primitive never plays audio -- it dispatches a `hf:sfx` CustomEvent
    ({ id: "click-soft", t: FLIP_AT }) that a scene's mix stage can catch and
    route to the catalog SFX id of its choice.

    Mount contract: this file is a MOUNTABLE SUB-COMPOSITION, not a standalone
    composition. A host loads it via data-composition-src; the runtime only
    clones <template> contents (everything outside <template>, including the
    entire <head>, is discarded on mount) -- see
    skills/hyperframes-core/references/sub-compositions.md. The root carries
    no data-width/data-height: it is elastic, sized off whatever box the host
    clip gives it (position:absolute; inset:0; container-type:size), so the
    toggle reads correctly whether the host mounts it into a 960x540 slot or a
    full 1920x1080 frame. The root is styled by #root, never a class --
    composited renders scope this file's CSS to
    [data-composition-id="toggle-flip"], and a rule keyed on the root's own
    class would stop matching the root itself (sub-compositions.md, Pitfall
    3). Variables are read via window.__hyperframes.getVariables() (not by
    parsing this file's own <html> tag at runtime): once mounted,
    document.documentElement is the HOST's <html>, not this one, so the
    loader's declared-defaults-plus-per-instance-overrides table is the only
    reliable source once the primitive is running inside a host page.
  -->
  <html
    lang="en"
    data-composition-variables='[
      { "id": "direction", "type": "enum", "role": "content", "label": "Flip direction", "description": "Target end state the flip lands on.", "default": "on", "options": [{ "value": "on", "label": "Off → On" }, { "value": "off", "label": "On → Off" }] },
      { "id": "label", "type": "string", "role": "content", "label": "Caption", "description": "Optional caption under the toggle. Blank hides the line.", "default": "Auto-save" },
      { "id": "size", "type": "number", "role": "layout", "label": "Size", "description": "Toggle width as a percent of the host box width.", "default": 40, "min": 20, "max": 70, "step": 1, "unit": "%" }
    ]'
  >
    <head>
      <meta charset="UTF-8" />
      <title>Toggle Flip</title>
      <!-- Metadata only for whoever opens this file directly -- the runtime
           discards everything outside <template> on mount. The
           data-composition-variables attribute above stays on <html> (not
           inside <template>): the loader reads declared variable defaults
           directly off the fetched document's root element, whether or not
           the body is template-wrapped. -->
    </head>
    <body>
      <template>
        <div id="root" data-composition-id="toggle-flip" data-duration="4" data-fps="30">
          <style>
            *,
            *::before,
            *::after {
              box-sizing: border-box;
            }

            /* Root: sized off the HOST box, never a fixed stage. inset:0 fills
               whatever box the host clip gives it -- a 960x540 mounted slot,
               a full 1920x1080 frame, anything in between. container-type
               establishes the cqw/cqh basis every internal measurement below
               is expressed in. Styled by #root, never a class -- see the
               mount-contract note in the header comment. */
            #root {
              position: absolute;
              inset: 0;
              container-type: size;
              isolation: isolate;
              overflow: hidden;
              background: var(--bg, #0b1120);
              color: var(--fg, #f8fafc);
              font-family: var(--font-body, Inter, system-ui, sans-serif);
            }

            .tf-clip {
              width: 100%;
              height: 100%;
              display: grid;
              place-items: center;
            }

            .tf-stage {
              display: flex;
              flex-direction: column;
              align-items: center;
              gap: var(--space-3, 3cqh);
              opacity: 0;
            }

            .tf-label {
              color: var(--muted, #94a3b8);
              font-family: var(--font-display, Inter, system-ui, sans-serif);
              font-weight: 650;
              font-size: clamp(12px, 2.4cqw, 28px);
              letter-spacing: 0.01em;
              text-align: center;
            }

            .tf-label:empty {
              display: none;
            }

            /* EDIT ZONE: geometry ratios. Track is a fixed 2.2:1 pill; the knob is
               80% of the track's own height, inset 10% top/bottom. All of it is
               expressed in cqw off --tf-size so it holds proportion at any size. */
            .tf-toggle {
              --tf-track-w: calc(var(--tf-size, 40) * 1cqw);
              width: var(--tf-track-w);
              aspect-ratio: 2.2 / 1;
              position: relative;
              display: block;
              border: 0;
              padding: 0;
              background: transparent;
              cursor: default;
              /* transform is GSAP-owned below (press squeeze); no CSS transform here */
            }

            .tf-track {
              position: absolute;
              inset: 0;
              border-radius: 999px;
              --tf-mix: 0; /* 0..100, GSAP-tweened: 0 = surface/off, 100 = brand/on */
              border: 0.15cqw solid
                color-mix(
                  in srgb,
                  var(--border, #334155) calc(100% - var(--tf-mix, 0) * 0.4%),
                  var(--brand, #22c55e) calc(var(--tf-mix, 0) * 0.4%)
                );
              background: color-mix(
                in srgb,
                var(--surface, #1e293b) calc((100 - var(--tf-mix, 0)) * 1%),
                var(--brand, #22c55e) calc(var(--tf-mix, 0) * 1%)
              );
              box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border, #334155) 45%, transparent);
            }

            .tf-glow {
              position: absolute;
              inset: -14%;
              border-radius: 999px;
              background: radial-gradient(
                closest-side,
                color-mix(in srgb, var(--accent, #38bdf8) 55%, transparent),
                transparent 72%
              );
              opacity: 0;
              pointer-events: none;
            }

            .tf-knob {
              position: absolute;
              top: calc(var(--tf-size, 40) * 0.0455cqw);
              left: calc(var(--tf-size, 40) * 0.0455cqw);
              width: calc(var(--tf-size, 40) * 0.3636cqw);
              aspect-ratio: 1;
              border-radius: 50%;
              background: var(--surface, #f8fafc);
              box-shadow: 0 0.35cqw 0.7cqw rgba(2, 6, 23, 0.4);
              /* RETIME RANGE ends here for geometry; 150% is the exact travel a
                 2.2:1 track / 80%-of-height knob pair produces -- see header. GSAP
                 tweens x/scale directly below; no CSS transform authored here. */
            }
          </style>

          <div
            id="toggle-flip-clip"
            class="tf-clip clip"
            data-start="0"
            data-duration="4"
            data-track-index="0"
          >
            <div class="tf-stage">
              <div class="tf-label"></div>
              <button
                class="tf-toggle"
                type="button"
                data-anchor="toggle-flip"
                aria-pressed="false"
                tabindex="-1"
              >
                <span class="tf-glow"></span>
                <span class="tf-track"></span>
                <span class="tf-knob"></span>
              </button>
            </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");
              // Literal, not read off the DOM: once mounted, the framework's
              // flattening step strips data-composition-id from the mounted
              // root (see FLATTENED_INNER_ROOT_STRIP_ATTRS in
              // packages/core/src/runtime/flattenedRoot.ts), so
              // root.getAttribute("data-composition-id") would read null post-
              // mount and silently register the timeline under the key
              // "null" instead of "toggle-flip" -- the render then waits the
              // full 45s sub-composition poll and gives up. Matches the
              // convention in sub-compositions.md's own example and
              // empty.html: hardcode the id this file is always mounted
              // under.
              var compositionId = "toggle-flip";
              var stage = root.querySelector(".tf-stage");
              var labelEl = root.querySelector(".tf-label");
              var toggleEl = root.querySelector(".tf-toggle");
              var trackEl = root.querySelector(".tf-track");
              var knobEl = root.querySelector(".tf-knob");
              var glowEl = root.querySelector(".tf-glow");

              // EDIT ZONE: variable defaults come from data-composition-variables
              // above (declared on <html>). window.__hyperframes.getVariables()
              // returns those defaults already merged with any per-instance
              // data-variable-values a host sets on the mounting clip -- see
              // sub-compositions.md's "Per-Instance Variables" section. Add a
              // variable in both places together (declaration above + fallback
              // below).
              var vars =
                window.__hyperframes && window.__hyperframes.getVariables
                  ? window.__hyperframes.getVariables()
                  : {};

              // INVARIANT: only "on" | "off" ever reaches the renderer, whatever a
              // bad override sends.
              var direction = vars.direction === "off" ? "off" : "on";
              var toOn = direction === "on";
              var labelText = vars.label == null ? "" : String(vars.label);
              // INVARIANT: size always clamps into the declared 20-70% range so an
              // out-of-range override can't blow the toggle past its container.
              var sizePct = Math.max(20, Math.min(70, Number(vars.size) || 40));

              labelEl.textContent = labelText;
              toggleEl.style.setProperty("--tf-size", String(sizePct));

              // A project's own --dur-beat sets this primitive's ambient pace, so
              // the HOLD breathing feels native to whatever theme is compiled in.
              var beat = parseFloat(getComputedStyle(root).getPropertyValue("--dur-beat")) || 0.5;
              var SHEEN_HALF = beat * 1.5;

              // RETIME RANGE: these are the only numbers to touch for a global
              // pacing retune. Do not reach for gsap.timeScale() -- HOLD below is
              // the only elastic phase; IN and OUT are always this long (scaled
              // down together only when D itself is shorter than IN_BASE+OUT_BASE).
              var IN_BASE = 0.9;
              var OUT_BASE = 0.5;
              var STAGE_IN_BASE = 0.4;
              var PRESS_LEAD_BASE = 0.2;
              var FLIP_AT_BASE = 0.5; // sync point: fixed offset into IN, never into HOLD
              var FLIP_DURATION_BASE = 0.25;
              var GLOW_DURATION_BASE = 0.15;

              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 PRESS_LEAD = PRESS_LEAD_BASE * scale;
              var FLIP_AT = FLIP_AT_BASE * scale;
              var FLIP_DURATION = FLIP_DURATION_BASE * scale;
              var GLOW_DURATION = GLOW_DURATION_BASE * scale;
              var HOLD = Math.max(0, duration - (IN + OUT));
              var HOLD_START = IN;
              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 (gsap.set, never gsap.from()) so a
              // seek to t=0 is always correct without playing the timeline.
              var mixFrom = toOn ? 0 : 100;
              var mixTo = toOn ? 100 : 0;
              var xFrom = toOn ? "0%" : "150%";
              var xTo = toOn ? "150%" : "0%";
              gsap.set(stage, { opacity: 0 });
              gsap.set(toggleEl, { scaleX: 1, scaleY: 1 });
              gsap.set(knobEl, { x: xFrom, scale: 1 });
              gsap.set(trackEl, { "--tf-mix": mixFrom });
              gsap.set(glowEl, { opacity: 0 });
              toggleEl.setAttribute("aria-pressed", toOn ? "false" : "true");

              var tl = gsap.timeline({ paused: true });

              // IN: stage settle
              tl.to(stage, { opacity: 1, duration: STAGE_IN, ease: "power2.out" }, 0);

              // anticipation press, resolves exactly as the flip begins
              if (PRESS_LEAD > 0) {
                var pressStart = Math.max(0, FLIP_AT - PRESS_LEAD);
                var pressHalf = PRESS_LEAD / 2;
                tl.to(
                  toggleEl,
                  { scaleX: 0.94, scaleY: 1.06, duration: pressHalf, ease: "power1.inOut" },
                  pressStart,
                );
                tl.to(
                  toggleEl,
                  { scaleX: 1, scaleY: 1, duration: pressHalf, ease: "power1.inOut" },
                  pressStart + pressHalf,
                );
              }

              // the flip: knob travel with overshoot + track color crossfade, one
              // shared sync point (FLIP_AT) so they always land together
              tl.to(knobEl, { x: xTo, duration: FLIP_DURATION, ease: "back.out(1.9)" }, FLIP_AT);
              tl.to(
                trackEl,
                { "--tf-mix": mixTo, duration: FLIP_DURATION, ease: "back.out(1.9)" },
                FLIP_AT,
              );
              tl.fromTo(
                knobEl,
                { scale: 1 },
                {
                  scale: 1.08,
                  duration: FLIP_DURATION * 0.45,
                  ease: "power1.out",
                  yoyo: true,
                  repeat: 1,
                },
                FLIP_AT,
              );
              tl.set(
                toggleEl,
                { attr: { "aria-pressed": toOn ? "true" : "false" } },
                FLIP_AT + FLIP_DURATION,
              );
              tl.call(
                function () {
                  fireSfx("click-soft", FLIP_AT);
                },
                [],
                FLIP_AT,
              );

              // soft glow pulse right as the flip settles
              tl.fromTo(
                glowEl,
                { opacity: 0 },
                {
                  opacity: 1,
                  duration: GLOW_DURATION * 0.5,
                  ease: "power1.out",
                  yoyo: true,
                  repeat: 1,
                },
                FLIP_AT + FLIP_DURATION,
              );

              // HOLD: gentle ambient breathing so an elastic frame still reads
              // alive; naturally absent when HOLD == 0. Finite repeat count per
              // the determinism rule -- floor, never ceil, so it can't overshoot D.
              if (HOLD > SHEEN_HALF) {
                var breathRepeat = Math.max(0, Math.floor(HOLD / SHEEN_HALF) - 1);
                tl.fromTo(
                  glowEl,
                  { opacity: 0 },
                  {
                    opacity: 0.32,
                    duration: SHEEN_HALF,
                    ease: "sine.inOut",
                    yoyo: true,
                    repeat: breathRepeat,
                  },
                  HOLD_START + GLOW_DURATION,
                );
              }

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

Tagged `prop` `ui-props` `toggle` `interaction` `reference-prop` `demonstrate`.

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [State Chip Rail](/catalog/components/state-chip-rail.md)
- [Notification Stack](/catalog/components/notification-stack.md)
- [Radial Surround](/catalog/components/radial-surround.md)
