> ## Documentation Index
> Fetch the complete documentation index at: /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.

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

**Toggle Flip**

```html toggle-flip.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>
```

Copy npx hyperframes add toggle-flip to the clipboard

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

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

## 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)


## Related topics

- [Settings Toggle Flow](/catalog/components/settings-toggle-flow.md)
- [Edit in the browser editor](/studio/index.md)
- [3D Transitions](/catalog/blocks/transitions-3d.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.