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

# Device Frame Stage

> A phone or tablet mockup staged as a physical scene prop with a screen slot for arbitrary reconstructed UI, rising onto the stage, settling, and holding an idle float

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="device-frame-stage 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/device-frame-stage.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/device-frame-stage.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add device-frame-stage" />

That writes one file: `compositions/components/device-frame-stage.html`.

## Paste it into your composition

Open `compositions/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`      |              |

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="device-frame-stage"
  data-composition-src="compositions/components/device-frame-stage.html"
  data-variable-values='{"device":"phone","cutout":"none","body":"graphite"}'
></div>
```

## Source

<Accordion title={`device-frame-stage.html`}>
  ```html theme={null}
  <!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>
  ```
</Accordion>

Tagged `prop` `device` `mobile` `tablet` `ui-props` `holdable`.

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

- [Browser Device Stage](/catalog/components/browser-device-stage.md)
- [Parallax Device Dive](/catalog/components/parallax-device-dive.md)
- [Multi Device Splay](/catalog/components/multi-device-splay.md)
