Install
That writes one file:compositions/components/device-frame-stage.html.
Paste it into your composition
Opencompositions/components/device-frame-stage.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 |
|---|---|---|---|
device | phone | phone, tablet | |
cutout | none | none, island, notch | |
body | graphite | graphite, silver |
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="device-frame-stage"
data-composition-src="compositions/components/device-frame-stage.html"
data-variable-values='{"device":"phone","cutout":"none","body":"graphite"}'
></div>
Source
device-frame-stage.html
device-frame-stage.html
<!doctype html>
<html
lang="en"
data-composition-id="device-frame-stage"
data-composition-duration="5"
data-composition-variables='[
{ "id": "device", "type": "enum", "role": "layout", "label": "Device kind", "default": "phone", "options": [{ "value": "phone", "label": "Phone" }, { "value": "tablet", "label": "Tablet" }] },
{ "id": "cutout", "type": "enum", "role": "layout", "label": "Camera cutout", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "island", "label": "Island" }, { "value": "notch", "label": "Notch" }] },
{ "id": "body", "type": "enum", "role": "style", "label": "Body material", "default": "graphite", "options": [{ "value": "graphite", "label": "Graphite" }, { "value": "silver", "label": "Silver" }] }
]'
>
<!--
Device Frame Stage, a HyperFrames video primitive.
Concept: a phone or tablet mockup treated as a physical scene prop, not a
screenshot border. It stages arbitrary reconstructed UI in a screen slot,
rises onto the stage as one rigid prop, settles, idles, and holds. The
frame serves the screen (device-frame-spec's governing law): no spinning
hardware reveal, glare sweep, or camera-lens tour, ever.
This file is a mountable HyperFrames sub-composition (see
skills/hyperframes-core/references/sub-compositions.md): a host
composition embeds it via a clip carrying data-composition-src, a
matching data-composition-id, and its own data-start / data-duration /
data-track-index / data-width / data-height. Everything the render needs
lives inside <template> below; the #root div's own data-width /
data-height are declarative reference metadata only (the linter's
root-dimensions contract) -- the CSS never reads them, and the device
sizes relative to whatever host box it actually lands in (cqh/cqw), so
it never needs to be opened or rendered standalone. See demo.html for
the reference mount.
Compiled from: fixture + mobile mechanics inventory #1 + device-frame-spec.md
(~/dev/hyperframes-corpus-data/mobile/device-frame-spec.md), the
authoritative geometry / material / entry-law source for this primitive.
Use when: a scene needs to stage mobile or tablet UI as a physical
product (hero product demo, gesture-led interaction, screen swap) rather
than a bare floating screenshot.
Variables (data-composition-variables above):
device enum phone|tablet default "phone" canonical outer ratio + logical viewport
cutout enum none|island|notch default "none" camera cutout; must match the installed screen content's safe area
body enum graphite|silver default "graphite" device material tone
Envelope (IN / HOLD / OUT, elasticity lives in HOLD only, never gsap.timeScale):
IN 1.08s fixed rise-and-settle: 0.18s pre-roll, 0.72s power3.out rise past rest, 0.18s power2.inOut settle
HOLD elastic idle float loop (period 3.2s, +/-0.6cqh), stretches or compresses to fill whatever duration remains
OUT 0.56s fixed sink, power2.in
Sync points (fixed offsets into IN/OUT, never inside the elastic HOLD):
device-settled t = IN (end of the settle segment) — screen content may start animating here
Sound cues: one SFX hook, "device-settle-soft", fired as an `hf:sfx`
CustomEvent at the device-settled sync point (t = IN). The primitive
never plays audio itself; the host scene mixes the cue.
-->
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Device Frame Stage</title>
<!-- head is metadata for the source file only; the runtime only clones
<template> contents on mount (see sub-compositions.md), so the gsap
loader here exists purely for opening this file directly during
authoring -- a host composition already provides window.gsap. -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head>
<body>
<template>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
/* INVARIANT: root is styled by #root, never a class -- the compiler
scopes CSS to [data-composition-id="device-frame-stage"] as a
descendant selector, so a rule keyed on the root's own class would
never match the root itself (see sub-compositions.md, Pitfall 3).
This primitive is elastic and fills whatever box a host clip
gives it, from a 960x540 slot up to a full 1920x1080 frame.
The width/height !important below is load-bearing, not decoration:
when this root gets inlined into a host, the compiler's
flatten step (prepareFlattenedInnerRoot) reads this element's own
data-width/data-height (required by the root_missing_dimensions
lint rule) and force-writes them back as an inline
style="width:1920px;height:1080px". Inline style beats a plain
stylesheet rule, so without !important the root would render at a
fixed 1920x1080 instead of filling the host box -- exactly the
2x-oversized-and-bleeding bug this primitive was fixed for. */
#root {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
container-type: size;
isolation: isolate;
}
.device-stage {
--stage-perspective: 1600px;
position: absolute;
inset: 0;
perspective: var(--stage-perspective);
perspective-origin: 50% 50%;
}
.device-frame {
/* INVARIANT: geometry below is physical-material law from
device-frame-spec.md (corner/bezel/aspect percentages, gradient,
shadow), not brand paint. It may carry literal fallbacks per the
primitive's material exception; do not swap in --radius/--space-*. */
--device-color: #1b1d22;
--device-color-hi: #343740;
--device-color-dark: #111318;
--device-radius: 46px;
--device-bezel: 9px;
--screen-radius: calc(var(--device-radius) - var(--device-bezel));
--device-aspect: 9 / 19.5;
--device-y: 18cqh;
--device-scale: 0.94;
position: absolute;
left: 50%;
top: 50%;
height: 90cqh;
aspect-ratio: var(--device-aspect);
border-radius: var(--device-radius);
padding: var(--device-bezel);
background: linear-gradient(
145deg,
var(--device-color-hi),
var(--device-color) 42%,
var(--device-color-dark)
);
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow:
0 52px 110px rgba(7, 10, 20, 0.3),
0 18px 38px rgba(7, 10, 20, 0.22),
0 3px 9px rgba(7, 10, 20, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.24),
inset 0 0 0 1px rgba(0, 0, 0, 0.48);
transform: translate(-50%, calc(-50% + var(--device-y))) scale(var(--device-scale));
transform-style: preserve-3d;
transform-origin: 50% 88%;
will-change: transform;
}
/* EDIT ZONE: kind/cutout/body are driven by data-composition-variables
(see JS below); an installing agent may also override
--device-color / --device-color-hi / --device-color-dark inline for
a bespoke finish without touching the geometry laws. */
.device-frame[data-kind="tablet"] {
--device-radius: 48px;
--device-bezel: 16px;
--screen-radius: 34px;
--device-aspect: 3 / 4;
height: 82cqh;
}
.device-frame[data-body="silver"] {
--device-color: #c9cdd3;
--device-color-hi: #eef1f4;
--device-color-dark: #9a9ea6;
}
.device-screen {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: var(--screen-radius);
background: var(--surface, #f5f6f8);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.42);
isolation: isolate;
container-type: size;
}
.screen-viewport {
/* Logical-pixel authoring space (390x844 phone / 768x1024 tablet),
uniformly scaled to the actual screen box by JS-measured
--screen-scale (device-frame-spec, "Screen content contract"). The
scale is scene setup, not animation. */
position: absolute;
left: 0;
top: 0;
width: calc(var(--logical-width, 390) * 1px);
height: calc(var(--logical-height, 844) * 1px);
transform-origin: 0 0;
transform: scale(var(--screen-scale, 1));
}
.screen-safe-area {
height: 100%;
box-sizing: border-box;
padding-top: calc(var(--logical-height, 844) * 1px * 0.03);
}
.device-frame[data-cutout="island"] .screen-safe-area {
padding-top: calc(var(--logical-height, 844) * 1px * 0.075);
}
.device-frame[data-cutout="notch"] .screen-safe-area {
padding-top: calc(var(--logical-height, 844) * 1px * 0.06);
}
.device-screen-slot {
position: relative;
width: 100%;
height: 100%;
}
/* Visible only while the slot is empty (comments do not count toward
:empty); vanishes the moment an installing agent drops real content
in, no cleanup required. */
.device-screen-slot:empty {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border, rgba(15, 23, 42, 0.16));
border-radius: calc(var(--screen-radius) * 0.4);
}
.device-screen-slot:empty::before {
content: "screen slot: replace with reconstructed UI";
padding: var(--space-2, 16px);
color: var(--muted, #94a3b8);
font-family: var(--font-body, Inter, system-ui, sans-serif);
font-size: 15px;
text-align: center;
}
.device-cutout {
position: absolute;
left: 50%;
top: 0;
z-index: 50;
width: 0;
height: 0;
background: #050607;
pointer-events: none;
transform: translateX(-50%);
}
.device-frame[data-cutout="island"] .device-cutout {
top: 1.4%;
width: 28%;
height: 3.2%;
border-radius: 999px;
}
.device-frame[data-cutout="notch"] .device-cutout {
top: 0;
width: 52%;
height: 3.8%;
border-radius: 0 0 18px 18px;
}
</style>
<!-- data-width / data-height here satisfy the linter's root-dimensions
contract (root_missing_dimensions) and match every other
sub-composition's #root -- see sub-compositions.md's own
file-shape example. The compiler's flatten step also reads these
two attributes and force-writes them back as an inline
width/height style on mount, so #root's width/height !important
above is required to keep the rendered box elastic (fills the
host clip's actual box, from a 960x540 slot up to a full
1920x1080 frame) instead of a fixed 1920x1080. -->
<div
id="root"
data-composition-id="device-frame-stage"
data-width="1920"
data-height="1080"
data-duration="5"
>
<div class="device-stage">
<div class="device-frame" data-kind="phone" data-cutout="none" data-body="graphite">
<div class="device-screen">
<div class="screen-viewport">
<div class="screen-safe-area">
<div class="device-screen-slot" data-screen-slot>
<!-- EDIT ZONE: replace with reconstructed product UI, or
nest a <div data-composition-src="..." data-duration="...">
sub-composition host. Author content at the device's
logical viewport (390x844 phone, 768x1024 tablet);
this slot is pre-scaled for you, so keep coordinates
in logical screen pixels, never canvas pixels. -->
</div>
</div>
</div>
</div>
<div class="device-cutout" aria-hidden="true"></div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
var html = document.documentElement;
var root = document.getElementById("root");
var frame = root.querySelector(".device-frame");
var screen = root.querySelector(".device-screen");
var viewport = root.querySelector(".screen-viewport");
var declared = {};
try {
JSON.parse(html.getAttribute("data-composition-variables") || "[]").forEach(
function (variable) {
declared[variable.id] = variable.default;
},
);
} catch (error) {}
var vars = Object.assign({}, declared, window.__hfVariables || {});
var device = vars.device === "tablet" ? "tablet" : "phone";
var cutout = vars.cutout === "island" || vars.cutout === "notch" ? vars.cutout : "none";
var body = vars.body === "silver" ? "silver" : "graphite";
frame.setAttribute("data-kind", device);
frame.setAttribute("data-cutout", cutout);
frame.setAttribute("data-body", body);
var LOGICAL = { phone: [390, 844], tablet: [768, 1024] };
var logicalWidth = LOGICAL[device][0];
var logicalHeight = LOGICAL[device][1];
viewport.style.setProperty("--logical-width", String(logicalWidth));
viewport.style.setProperty("--logical-height", String(logicalHeight));
function updateScreenScale() {
var rect = screen.getBoundingClientRect();
if (rect.width > 0)
viewport.style.setProperty("--screen-scale", String(rect.width / logicalWidth));
}
updateScreenScale();
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(updateScreenScale).observe(screen);
}
// NOTE: the timeline registration key below is a literal string,
// not read from root.getAttribute("data-composition-id") -- when
// this file is mounted via data-composition-src, the compiler
// strips data-composition-id from the inlined root (the HOST
// clip keeps its own copy instead), so a live read here would
// return null and the host would never find this timeline (see
// sub-compositions.md, Pitfall 2). registry/test-scenes/slots/empty.html
// models the same literal-key pattern.
var compositionId = "device-frame-stage";
var duration = Math.max(
0.001,
parseFloat(
root.dataset.duration || html.getAttribute("data-composition-duration") || "5",
),
);
// INVARIANT: IN/OUT durations are fixed by device-frame-spec's entry
// and exit law; only HOLD stretches or compresses to fit whatever
// duration the host gives this instance. Never gsap.timeScale().
var IN_DELAY = 0.18;
var IN_RISE = 0.72;
var IN_SETTLE = 0.18;
var IN_BASE = IN_DELAY + IN_RISE + IN_SETTLE; // 1.08s
var OUT_BASE = 0.56;
var totalBase = IN_BASE + OUT_BASE;
var IN = IN_BASE;
var OUT = OUT_BASE;
if (duration < totalBase) {
var shrink = duration / totalBase;
IN = IN_BASE * shrink;
OUT = OUT_BASE * shrink;
}
var HOLD = Math.max(0, duration - (IN + OUT));
var OUT_AT = IN + HOLD;
// SYNC POINT: device-settled fires at t = IN (fixed offset into IN,
// never inside the elastic HOLD).
function easeOutCubic(x) {
return 1 - Math.pow(1 - x, 3);
}
function easeInOutQuad(x) {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
}
function easeInQuad(x) {
return x * x;
}
function setPose(yCqh, scaleValue) {
frame.style.setProperty("--device-y", yCqh.toFixed(3) + "cqh");
frame.style.setProperty("--device-scale", scaleValue.toFixed(4));
}
function renderIn(t) {
var delayEnd = IN_DELAY * (IN / IN_BASE);
var riseEnd = delayEnd + IN_RISE * (IN / IN_BASE);
if (t <= delayEnd) {
setPose(18, 0.94);
return;
}
if (t <= riseEnd) {
var p = easeOutCubic(
Math.min(1, (t - delayEnd) / Math.max(0.0001, riseEnd - delayEnd)),
);
setPose(18 + (-1.2 - 18) * p, 0.94 + (1.01 - 0.94) * p);
return;
}
var p2 = easeInOutQuad(Math.min(1, (t - riseEnd) / Math.max(0.0001, IN - riseEnd)));
setPose(-1.2 + (0 - -1.2) * p2, 1.01 + (1 - 1.01) * p2);
}
// RETIME RANGE: the idle float is the only elastic segment. It
// repeats for as long as HOLD lasts, so stretching the host duration
// never re-times the fixed IN or OUT phases above.
function renderHold(holdT) {
var period = 3.2;
var amp = 0.6;
var phase = ((holdT % period) / period) * Math.PI * 2;
setPose(Math.sin(phase) * amp, 1);
}
function renderOut(p) {
var e = easeInQuad(Math.min(1, p));
setPose(0 + 18 * e, 1 + (0.96 - 1) * e);
}
function render(t) {
if (t <= IN) renderIn(t);
else if (t < OUT_AT) renderHold(t - IN);
else renderOut((t - OUT_AT) / Math.max(0.0001, OUT));
}
function fireSfx(id, atTime) {
root.dispatchEvent(
new CustomEvent("hf:sfx", { detail: { id: id, t: atTime }, bubbles: true }),
);
}
var proxy = { t: 0 };
var tl = gsap.timeline({ paused: true });
tl.to(proxy, {
t: duration,
duration: duration,
ease: "none",
onUpdate: function () {
render(proxy.t);
},
});
tl.call(
function () {
fireSfx("device-settle-soft", IN);
},
[],
IN,
);
tl.to({}, { duration: Math.max(0, duration - tl.duration()) });
render(0);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</template>
</body>
</html>
prop device mobile tablet ui-props holdable.