PreviewAdapter interface decouples the editing model from the visual surface. For browser-based editors, createIframePreviewAdapter bridges the SDK to a same-origin <iframe> containing the composition, giving you synchronous hit-testing, 60fps drag preview, and selection management — all without touching the model until the user commits.
The iframe must be same-origin (e.g.
srcdoc or a blob: URL). Cross-origin iframe access throws a DOMException; the adapter does not guard this, so enforcing same-origin is the caller’s responsibility.Embedding the composition
Render the composition HTML into a same-origin<iframe> in your editor shell, then pass that element plus a dispatch callback to createIframePreviewAdapter:
comp before it is declared — that is intentional and safe: the arrow function captures comp by closure and is only ever invoked later (by commitPreview() on pointer-up), by which point comp is assigned. This is the standard way to break the adapter ⇄ session circular dependency.
The dispatch callback is optional. Omitting it means commitPreview() is a no-op, which is useful if you want to handle op derivation yourself.
Keeping the preview in sync
Everything above wires up hit-testing and drag — but the iframe still won’t reflect edits made any other way (an inspector panel callingcomp.setStyle() directly, an undo, a collaborator’s change replayed via applyPatches()). attachSync closes that gap: call it once you have both preview and comp, and every future edit — including undo/redo — mirrors onto the live iframe automatically.
attachSync does an immediate full sync of comp’s current state first (so re-opening a composition with existing overrides isn’t a blank iframe), then subscribes to the same patch event your other listeners use. You don’t need to write your own mirroring code, and you don’t need a separate mechanism for undo/redo — both flow through the same subscription. Script-tag edits (GSAP script rewrites) are the one thing it never mirrors, since replaying a live <script> tag doesn’t re-execute it.
Calling
attachSync again with a different comp detaches the previous subscription first — useful if your editor swaps which composition an iframe is bound to without remounting it.Hit-testing: finding what the user clicked
preview.elementAtPoint(x, y) performs a synchronous hit-test at coordinates in the iframe’s own coordinate space and returns the nearest [data-hf-id] element, or null for a transparent hit.
0 (including ancestors with opacity: 0), and for <img> elements it samples the alpha at the clicked pixel using an offscreen canvas — a transparent pixel falls through to the element behind it. Cross-origin images that taint the canvas fall back to treating the pixel as opaque.
The opts.atTime parameter is accepted but does not seek the GSAP timeline. It reflects whatever frame the composition is currently paused at in the iframe. Accurate out-of-time-band opacity queries are a future capability.
Walking a click target to the nearest HF element
If you are working with events on the iframe’scontentDocument directly (e.g. via a message bridge), use the exported resolveNearestHfElement function. It walks up the DOM from any node until it finds a [data-hf-id] ancestor, skipping the root:
resolveNearestHfElement returns null when the walk exits the tree without finding a [data-hf-id] node, when the matching node carries [data-hf-root] (the root is transparent to selection), or when isVisible returns false for that node.
Transparent compositions over other content
A composition authored as an overlay — a small graphic on an otherwise-empty 1080×1920 frame, layered over a video or an avatar — is still a rectangular DOM box covering every pixel of the frame. Without help it swallows every click, and whatever sits beneath it becomes unreachable.preview.isProvablyEmptyAt(x, y) is the question you need answered: is this point provably free of ink, so a click may safely reach what sits beneath? Toggle pointer-events on your wrapper from the answer, and let the browser deliver the event to the right target:
1
Decide before the press, not during it
The browser picks an event’s target before any handler runs, so flipping
pointer-events inside mousedown cannot retarget the click already in flight. Sample the pointer position on mousemove and keep the decision current.2
Re-evaluate on every frame, not only on movement
Animated artwork moves under a stationary cursor. Anything that can change the answer — pointer movement, the Alt key, and the playhead — has to re-run the query from the last known position. Coalesce those triggers into one
requestAnimationFrame query rather than answering each separately, and short-circuit before the query when the pointer is outside the composition’s box: it is a walk over the document, so it does not belong on an ungated per-event path.3
Let the polarity do the work
isProvablyEmptyAt is true only when it has established there is no ink. A document that hasn’t loaded, an adapter without the method, and a point you couldn’t map all come back falsy — which keeps the composition clickable. Don’t invert it into a “does it paint” variable; that reintroduces the bug the polarity removes.{ fullBleedFraction: 0.9 } if your editor treats a layer covering nearly the whole frame as background rather than artwork — a common choice, since a full-bleed wrapper is usually scaffolding rather than something the user is pointing at.
Do not reimplement this with elementsFromPoint. That stack omits pointer-events: none nodes, and a decorative overlay carrying pointer-events: none still paints — a z-stack query would report no ink over visible artwork and pass the click through anyway. The paint walk covers element boxes geometrically for that reason.
Draft loop: 60fps drag without model mutations
The draft loop keeps the model clean during a drag. The SDK is not in the 60fps path — you callpreview.applyDraft on every pointermove and preview.commitPreview once on pointerup. The model sees exactly one moveElement op per drag, rather than hundreds.
applyDraft sets the element’s CSS translate directly inside the iframe — the pre-drag value composed with the accumulated delta — so the drag is visible in any composition without composition-side CSS, and works on GSAP-animated elements (a translate set after GSAP’s first parse composes with the animated transform instead of being overwritten). Nothing in the SDK model changes. cancelPreview restores the pre-drag translate; commitPreview derives one moveElement op and mirrors the committed position onto the live element.
DraftProps accepts dx, dy, width, and height. Width and height are accepted by the interface but resize support (mapping to a setStyle op) is not yet wired — only dx/dy drive the draft CSS vars today.
Call cancelPreview() instead of commitPreview() to discard the drag without emitting any op. The model is never mutated and the CSS vars are cleared.
Selection
preview.select(ids, opts?) sets the selection state and fires the session’s selectionchange event on any listeners. Pass { additive: true } to extend the current selection rather than replace it.
comp.on("selectionchange", ...) — the adapter fires that event, not a separate event on the iframe.
Pairing with embedded override mode
For template-driven products you typically open the composition in embedded override mode and store only the sparse delta, not the full HTML. The preview adapter works identically in that mode — pass it the same way:What to build next
Once hit-testing and drag are working, you can use the affordance resolver to drive a context-aware inspector panel for whatever element is selected. See Editing Affordances for how to translate a live element into capability flags and section applicability.Adapter reference
Full
PreviewAdapter, PersistAdapter, and related type documentation.Editing Affordances
Resolve which edit controls to show for the selected element.