Install
That writes one file:compositions/components/toggle-flip.html.
Paste it into your composition
Opencompositions/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. |
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:
<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
toggle-flip.html
toggle-flip.html
<!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>
prop ui-props toggle interaction reference-prop demonstrate.