One scrolling page.
Two independent origins.
The entire report stays in one cross-origin iframe. The parent page scrolls; the report's navigation uses native CSS sticky positioning.
On desktop, a full-report-height wrapper contains a viewport-height sticky iframe. The bridge advances the report inside this window. The sidebar no longer needs a transform to cancel the parent's scrolling on every frame.
This public demo contains only generated boxes and lorem text. It contains no source report text, branding, images, charts, datasets, or production code. It uses no analytics, cookies, forms, or external libraries. Hosting providers still handle ordinary request logs. Search indexing is discouraged, not access-restricted.
Team and LLM handoff
Both teams must integrate this. The report team owns the child HTML, navigation, and messaging. The embedding-site team owns the wrapper, iframe size, header measurement, and parent scrolling. Child-only changes cannot provide the tested behavior.
The prompt below references structural selectors found in the supplied HTML. No original report content or datasets are included. Attach that HTML privately when sending the prompt.
Copyable implementation prompt
Implement the single-iframe native-sticky integration in the supplied original report HTML. This is an interim solution until the report becomes a native page. Keep the entire report, including its navigation, in ONE cross-origin iframe. Do not split or clone the navigation into the parent. Read these working references first: Guide: https://iframe-scroll-host-20260922.pages.dev/guide Parent: https://iframe-scroll-host-20260922.pages.dev/host.js Child: https://iframe-scroll-report-20260922.pages.dev/report.js Child styles: https://iframe-scroll-report-20260922.pages.dev/report.css Demo: https://iframe-scroll-host-20260922.pages.dev/ Inspect before editing: - Find .wrapper (candidate natural-height content root), .rd-content (sidebar boundary), .rd-toc (native-sticky navigation), and .rd-toc-list a (internal navigation links). - Verify existing targets: overview, trajectory, divide, intents, models, applications, about. Preserve these IDs and their existing content. Leave .rd-toc-report external links alone. - Find the final iframe-height script that sends microsoft-embed-resize and the final updateCurrentSection script. Replace or consolidate these owners; do not add competing controllers. - Preserve the current standalone styling, mobile breakpoint (TOC hidden at 1100px and below), charts, data, downloads, skip link, accessible tables, and reduced-motion behavior. Do not execute the intentionally disabled text/plain script. Architecture: 1. The parent scrolls. Wrap the single iframe in a natural-flow stage whose height equals the child report's full content height. 2. Only after a validated capability handshake, and on desktop, make the iframe position: sticky; top: 0, with height min(contentHeight, parent.innerHeight). Keep it borderless and unscaled. Check ancestor overflow and transforms rather than compensating with arbitrary offsets. 3. Parent sends the stage top as frameTop and the actual iframe top as frameViewportTop, both relative to the parent layout viewport. Also send visual viewport offsetTop/height, topInset, scrollMode, enabled, and an increasing sequence. 4. Parent batches geometry updates with one requestAnimationFrame. Child applies each validated viewport message immediately. Do NOT add a child requestAnimationFrame, easing, interpolation, prediction, wheel interception, or scroll-blocking listeners to this path. 5. In viewport mode, child scrollY = max(0, frameViewportTop - frameTop). Keep .rd-toc natively sticky inside .rd-content; sticky top = max(0, topInset - frameViewportTop). Limit its height to visible space and let it scroll internally in short windows. No per-frame sidebar transform is needed. 6. Scope overflow:hidden and height/min-height decoupling to embedded mode. Hidden root overflow allows programmatic child scrolling while normal wheel input passes to the parent in the tested browser. Verify real input routing in every target browser; do not assume it. 7. Measure the natural content root, not viewport-inflated documentElement.scrollHeight. Observe content/font/chart resizes, bound heights, and skip near-duplicates. Only one controller may set the stage/iframe height. Coordinate disabling or adapting the existing generic Embed auto-resize on this embed only. 8. Intercept only unmodified primary activation of valid internal TOC links while connected. Focus the target with preventScroll:true (add tabindex=-1 when necessary), send its child DOCUMENT position (rect.top + child.scrollY), and let the parent scroll to stage document top + target top - topInset. Preserve modified clicks and external links. Audit skip-link and other internal anchor behavior too. 9. Replace the competing active-section updater. Use each section's document position plus frameTop and the parent's visible viewport threshold; update aria-current="location". Standalone mode keeps its own normal viewport calculation. Security and lifecycle: - Confirm exact production/staging PARENT_ORIGIN and CHILD_ORIGIN with both teams. Do not guess them from the demo, accept arbitrary origins, or use '*' as targetOrigin. Demo and localhost origins are not production defaults. - Validate event.origin, event.source, channel, version, current per-load session, message type, safe increasing sequence, finite bounded geometry, boolean enabled, and the mode enum BEFORE writes/scrolling. Parent validates bounded heights and an explicit section-ID allowlist plus target bounds. - Advertise scrollMode:"viewport" in ready; use it only when both sides support it. Bind replies to the allowed initialized origin. Reset session/sequence on load. Ignore stale and wrong-source messages. - Replace the existing wildcard resize sender; do not leave it running alongside the new bridge. Coordinate any legacy resize support with the parent owner, with exact origins and source checks. - Retain a readable full-height fallback when capability is absent, bridge is off, or the viewport is mobile. Restore styles and child scroll appropriately when leaving viewport mode. Do not claim legacy transformed sticky unless that fallback is actually implemented. - Define and test handshake timeout/reload recovery; restore readable expanded mode on failure. Disconnect observers/listeners and cancel pending frames/timers on teardown. Avoid duplicate initialization. - Keep explicit parent frame-src and child frame-ancestors policies. Do not weaken CSP/sandbox to fix a stalled development tab; diagnose it first. No parent/child DOM access across origins and no CORS workaround are needed. Acceptance checks: - Actual slow trackpad input plus automated fractional wheel steps and reversals. Sample every frame while pinned, and prove parent AND report content moved; a stationary page is not a pass. - Desktop, mobile, short windows, viewport resize/zoom, entry/exit, report-bottom boundary and parent footer; bridge off/on; standalone; missing handshake and reload. - TOC activation, Tab/Shift+Tab, PageUp/PageDown/Home/End, target focus, short-window TOC scrolling, skip link and external downloads. Keep chart keyboard controls intact. - Original charts and lazy IntersectionObserver reveals, font-driven height changes, ResizeObserver redraws, fixed/hover/focus tooltips and scroll-dismiss behavior. Test with the real page locally, not by uploading its data to the public demo. - Reject wrong origins/sources/sessions/versions, stale sequences, nonfinite/out-of-range values, unknown modes and sections without scrolling or resizing. - Test Chrome/Edge, Firefox, Safari and touch hardware. The demo was verified in Chrome only; do not present that as cross-browser certification. Report movement still has postMessage latency even though the pinned sidebar is native-sticky. Deliver a focused patch, configuration values still needed, parent-team integration steps, tests with measured results, and remaining limitations. Do not edit report prose/data/branding, unrelated code, or production infrastructure. If parent code is unavailable, deliver the child change plus an explicit parent contract and mark the integration incomplete until both sides are tested.
Integration map for the supplied HTML
.wrapper: candidate measured root; verify it contains the complete report and is not inflated by viewport height or positioned tooltips..rd-content: existing two-column grid and native-sticky boundary. Keep.rd-tocas its non-stretched grid child; no extra TOC wrapper is required by this layout..rd-toc-list a: derive links and target elements here. The demo's#tocandsection-1identifiers are not present in the supplied page.microsoft-embed-resize: existing trailing script uses a wildcard target. Replace or deliberately adapt that protocol in agreement with the parent owner; do not retain a second height writer.updateCurrentSection: existing trailing script useswindow.innerHeight * 0.3. Consolidate it into the bridge-aware updater so it cannot overwrite the parent's active-section result.- Existing
IntersectionObserverreveals, chartResizeObserverhandlers, tooltip viewport calculations, and scroll-dismiss listeners need regression tests with internal child scrolling.
Try it
- Open the demo in a window wider than 1,100 CSS pixels and scroll past the introduction. The left navigation stays below the parent header.
- Select a section. The parent scrolls to it; keyboard focus moves to the section inside the iframe.
- Scroll to the bottom. Navigation stops at the report boundary and does not follow into the parent footer.
- Turn off Scroll bridge, then scroll. The iframe still auto-sizes, but its native sticky navigation scrolls out of view. Turn it back on to compare.
- Shorten the window. The navigation gets its own bounded scrollbar so all links remain reachable. Below 1,101 pixels wide, the TOC is hidden.
- Open the standalone report. It uses normal browser scrolling and native CSS sticky positioning.
Apply it to an existing embed
- Keep the height bridge. Measure a stable, natural-height content root in the child. Send its bounded height to the parent and apply it to a wrapper around the iframe. Remove viewport-dependent minimum heights in embed mode.
- Add a capability handshake. Parent sends an initialization message on iframe load; child acknowledges
scrollMode: "viewport". Pin both origins and validateevent.origin,event.source, a protocol version, and a per-load session identifier. Activate viewport mode only after agreement. - Make the iframe the sticky window. On desktop, use
position: sticky; top: 0and a height equal to the smaller of the report height and parent viewport height. The wrapper keeps the full report height in the parent's document flow. Keep ancestor overflow from creating another sticky scroll container. - Send two positions.
frameTopis the wrapper's viewport-relative top;frameViewportTopis the actual iframe's top. Also send visible viewport geometry, the header inset, enable state, scroll mode, and increasing sequence. Batch updates in the parent withrequestAnimationFrame. Validate and apply them immediately in the child, without another animation-frame wait. - Use native sticky inside the report. Set child scroll position to
max(0, frameViewportTop - frameTop). Set the navigation's sticky top tomax(0, topInset - frameViewportTop)and cap its height to the available visible space. The browser handles pinning and the report-bottom boundary. Do not add transform easing, prediction, or a second scroll-animation loop. The child root usesoverflow: hidden: programmatic scrolling works while ordinary wheel input continues to the parent. - Use the same geometry for active sections. Compare child section positions with the parent viewport, not the iframe's full height. Keep
aria-currentsynchronized with the visible section. - Route navigation to the parent. Child resolves the section, focuses it with
preventScroll, and sends its document-relative position. Parent validates the request and scrolls to wrapper document top plus target position minus its header inset. This demo uses instant navigation; optional smooth scrolling must respect reduced motion. - Retain fallbacks. Mobile and bridge-off mode use the full-height iframe. Standalone pages keep ordinary document scrolling and native sticky. Parents without the new capability retain the previous bounded-transform bridge; children without it remain full-height. This allows separate deployments without switching an old child into an unsupported layout.
- Roll out only to the intended embed. Use an opt-in capability or block setting, scope listeners to that iframe, and remove listeners when it is removed. Do not change the behavior of unrelated embeds.
Code examples
Adapt these excerpts; do not append a second bridge. The JavaScript examples share the names shown below. The linked parent and child files provide the runnable demo. Replace their demo selectors and configuration with the original page's values.
Before enabling the integration, both teams must agree on exact origins, the channel/version, allowed section IDs, a maximum content height, and failure recovery. The example origins below are deliberately nonfunctional placeholders.
1. Child selectors and scoped styles
Keep the existing grid, standalone sticky rule, and mobile media query. Toggle iframe-embedded only inside an iframe and iframe-viewport only after validated viewport-mode agreement. The TOC remains a direct child of .rd-content.
const root = document.documentElement;
const contentRoot = document.querySelector('.wrapper');
const toc = document.querySelector('.rd-toc');
const links = Array.from(document.querySelectorAll('.rd-toc-list a[href^="#"]'));
const sections = links.map(link => document.getElementById(link.hash.slice(1)));
if (!contentRoot || !toc || sections.some(section => !section)) {
throw new Error('Report bridge selectors do not match this document');
}
const embedded = window.parent !== window;
root.classList.toggle('iframe-embedded', embedded);
html.iframe-embedded,
html.iframe-embedded body {
height: auto;
min-height: 0;
}
html.iframe-embedded { overflow: hidden; }
html.iframe-viewport .rd-toc {
position: sticky;
top: var(--embed-toc-top, 24px);
max-height: var(--embed-toc-height, 700px);
align-self: start;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
transform: none;
}
2. Parent layout and coordinate mapping
stage is a wrapper inserted once around the existing iframe. Call this after a validated height message, with contentHeight between 100 and the agreed cap (50,000 CSS pixels in the demo). viewportMode requires capability agreement, an enabled bridge, and iframe width above 1,100px. Measure topInset from the actual pinned parent header(s), plus the desired gap, on each update.
function layoutFrame({ stage, frame, contentHeight, topInset, viewportMode }) {
stage.style.height = `${contentHeight}px`;
frame.style.position = viewportMode ? 'sticky' : '';
frame.style.top = viewportMode ? '0px' : '';
frame.style.height = `${viewportMode
? Math.min(contentHeight, window.innerHeight)
: contentHeight}px`;
return {
frameTop: stage.getBoundingClientRect().top,
frameViewportTop: frame.getBoundingClientRect().top,
viewportTop: window.visualViewport?.offsetTop || 0,
viewportHeight: window.visualViewport?.height || window.innerHeight,
topInset,
scrollMode: viewportMode ? 'viewport' : 'expanded',
};
}
Send this snapshot with channel, version: 1, the current session, type: 'viewport', boolean enabled, and sequence: ++sequence, to the exact child origin. Batch parent scroll, resize, visual viewport, and above-frame layout changes through one pending requestAnimationFrame. Do not put the child application function in another animation frame.
Parent CSS must keep the iframe display: block; width: 100%; border: 0. Restore pre-integration inline styles on teardown. A scroller created by an ancestor's overflow can change what sticky sticks to; test the real host layout.
3. Child handshake and validation gate
Run after selecting the elements in example 1. applyViewport and sendHeight are defined in example 4. This is the only inbound viewport owner. Register it with your component's abort signal or remove it on teardown. A new initialized session resets the sequence; never adopt an origin from an unvalidated message.
const channel = 'iframe-scroll-demo';
const allowedParents = new Set(['https://parent.example.invalid']);
let parentOrigin = '';
let session = '';
let lastSequence = 0;
let geometry = null;
let lastHeight = 0;
function post(type, payload = {}) {
if (!parentOrigin || !session) return;
window.parent.postMessage(
{ channel, version: 1, session, type, ...payload }, parentOrigin,
);
}
function validViewport(data) {
const numeric = ['frameTop', 'frameViewportTop', 'viewportTop',
'viewportHeight', 'topInset'];
return numeric.every(key => Number.isFinite(data[key]))
&& Number.isSafeInteger(data.sequence)
&& data.sequence > lastSequence
&& Math.abs(data.frameTop) <= 100000
&& Math.abs(data.frameViewportTop) <= 100000
&& Math.abs(data.viewportTop) <= 20000
&& data.viewportHeight >= 1 && data.viewportHeight <= 20000
&& data.topInset >= 0 && data.topInset <= 20000
&& typeof data.enabled === 'boolean'
&& ['viewport', 'expanded'].includes(data.scrollMode);
}
function onMessage(event) {
if (!embedded || event.source !== window.parent
|| !allowedParents.has(event.origin)) return;
const data = event.data;
if (!data || data.channel !== channel || data.version !== 1
|| typeof data.session !== 'string'
|| data.session.length < 1 || data.session.length > 80) return;
if (data.type === 'init') {
parentOrigin = event.origin;
session = data.session;
lastSequence = 0;
lastHeight = 0;
geometry = null;
post('ready', { scrollMode: 'viewport' });
sendHeight();
return;
}
if (event.origin !== parentOrigin || data.session !== session
|| data.type !== 'viewport' || !validViewport(data)) return;
lastSequence = data.sequence;
geometry = data;
applyViewport(data);
}
The parent must mirror the envelope checks against frame.contentWindow and the exact child origin. It must reject invalid height/navigation payloads before resizing or scrolling. The parent owns session creation, load-handshake retries/timeouts, capability gating, and a readable fallback. This excerpt does not implement those lifecycle policies for you.
4. Apply geometry immediately; measure natural height
updateCurrentSection is defined in example 5. In the new page integration, expanded mode is a readable, non-sticky embed fallback; this excerpt does not add the demo's older transform fallback. At desktop-to-mobile and bridge-off transitions the parent expands the frame and sends expanded mode.
function applyViewport(data) {
const viewportMode = data.enabled && data.scrollMode === 'viewport';
root.classList.toggle('iframe-viewport', viewportMode);
if (viewportMode) {
toc.style.setProperty('--embed-toc-top',
`${Math.max(0, data.topInset - data.frameViewportTop)}px`);
toc.style.setProperty('--embed-toc-height',
`${Math.max(80, data.viewportTop + data.viewportHeight - data.topInset - 24)}px`);
window.scrollTo({
top: Math.max(0, data.frameViewportTop - data.frameTop),
behavior: 'instant',
});
} else {
toc.style.removeProperty('--embed-toc-top');
toc.style.removeProperty('--embed-toc-height');
window.scrollTo({ top: 0, behavior: 'instant' });
}
updateCurrentSection();
}
function sendHeight() {
if (!embedded || !session) return;
const height = Math.ceil(contentRoot.getBoundingClientRect().height);
if (!Number.isFinite(height) || height < 100 || height > 50000
|| Math.abs(height - lastHeight) < 2) return;
lastHeight = height;
post('height', { height });
}
Measure .wrapper only after confirming its coverage and stable natural size. Observe it with ResizeObserver, remeasure after fonts and asynchronous assets settle, and disconnect on teardown. Batch repeated measurements if needed; that is separate from the immediate viewport-message path. Do not run the old wildcard height script as well.
5. Child navigation, active section, and parent target
Replace the existing active-section script; do not let two listeners compete for aria-current. Register the standalone updater for scroll/resize/load, and preserve its standalone behavior. Route other internal anchors, including the skip link, under an explicit policy agreed with the parent team.
function documentTop(element) {
return element.getBoundingClientRect().top + window.scrollY;
}
function updateCurrentSection() {
const connected = Boolean(geometry?.enabled);
const frameTop = connected ? geometry.frameTop : -window.scrollY;
const inset = connected ? geometry.topInset : 0;
const viewportBottom = connected
? geometry.viewportTop + geometry.viewportHeight : window.innerHeight;
const threshold = inset + Math.max(0, viewportBottom - inset) * 0.3;
let current = 0;
sections.forEach((section, index) => {
if (frameTop + documentTop(section) <= threshold) current = index;
});
links.forEach((link, index) => {
if (index === current) link.setAttribute('aria-current', 'location');
else link.removeAttribute('aria-current');
});
}
function onTocClick(event) {
if (!geometry?.enabled || event.defaultPrevented || event.button !== 0
|| event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const index = links.indexOf(event.currentTarget);
const section = sections[index];
if (!section) return;
event.preventDefault();
if (!section.hasAttribute('tabindex')) section.setAttribute('tabindex', '-1');
section.focus({ preventScroll: true });
post('navigate', { sectionId: section.id, top: documentTop(section) });
}
Attach onTocClick to the validated links from example 1. Remove those handlers on teardown. External report/download links are not in that collection.
On the parent, after validating origin/source/envelope and current session, handle navigation using the wrapper's document position, not the pinned iframe's position:
const sectionIds = new Set([
'overview', 'trajectory', 'divide', 'intents',
'models', 'applications', 'about',
]);
function navigateReport(data, { stage, contentHeight, topInset, ready, enabled }) {
if (!ready || !enabled || data.type !== 'navigate'
|| !sectionIds.has(data.sectionId)
|| !Number.isFinite(data.top) || data.top < 0 || data.top > contentHeight) return;
const target = window.scrollY + stage.getBoundingClientRect().top + data.top - topInset;
const maximum = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
window.scrollTo({ top: Math.max(0, Math.min(target, maximum)), behavior: 'instant' });
}
The section allowlist is a contract between both teams, not arbitrary DOM selectors received in messages. Update it deliberately when sections change. Deep links, browser history, and parent URL fragments need a separate agreed policy.
6. Wire the lifecycle and prove the result
- Initialize selectors once, attach the validated message handler, and attach internal link handlers. Observe the measured content root and await fonts. Parent re-initializes on iframe load with a fresh session; child resets state and replies with ready plus height.
- Parent enables viewport layout only after a compatible ready and valid height. Send current geometry after each layout change. On failure or teardown restore a usable full-height embed, release observers/listeners, and clear scheduled work.
- Use two different local origins. Never relax origin validation for a convenience preview URL. Test the real report privately with its assets and test the anonymous public demo separately.
- Start midway through the report and record both the iframe's parent-viewport top and the sidebar's child-viewport top on animation frames. Their sum should remain at the parent top inset while pinned. Also record both scroll positions and visible report content; a motionless report gives a misleading zero-jitter result.
- Send fractional wheel deltas in both directions and test a real trackpad. Test transitions, resize, keyboard focus, short-window scrolling and footer boundaries separately. Include all malformed-message cases in the handoff prompt.
The excerpts deliberately leave lifecycle wiring explicit rather than supplying a second opaque framework. Deliver the actual integration diff and evidence for every release check; do not mark an untested browser or the unmodified source report as passing.
WordPress demo setup
The anonymous child permits https://celacampaindev.wpenginepowered.com in both its framing policy and message allowlist. The page-specific parent bridge also permits that exact origin.
- Use the paste-ready page content in a new page's Gutenberg Code editor, not inside one Custom HTML block. It uses the existing Microsoft Embed block with full-width alignment and generic auto-resize disabled.
- The snippet loads the parent bridge externally from this demo host. Keep that script tag and the control IDs. Do not paste the script's source inline or add another height controller.
- Preview the front-end page in a separate tab. The connection status should read Connected. Above 1,100px iframe width, scroll past the introduction to check the sticky sidebar.
If WordPress removes the script tag, a developer must enqueue the bridge for this page only. If the parent has CSP restrictions, permit the demo host in its script-src (or script-src-elem, if defined) and the child report origin in frame-src, or host the parent script on the same site. Do not disable CSP globally. Mark the WordPress demo page noindex separately; the child's noindex does not cover its parent.
The setup is verified using a browser-local page at the WPEngine origin, including child CSP, handshake, navigation and mobile resizing. The actual WPEngine page must still be checked after saving; its WordPress permissions, plugin availability, and response headers are site-specific.
Message contract
| Direction | Message | Purpose |
|---|---|---|
| Parent to child | init | Establish an allowed origin and per-load session. |
| Child to parent | ready | Acknowledge the session and advertise viewport mode. |
| Child to parent | height | Set wrapper height from stable content dimensions; set iframe height in expanded mode. |
| Parent to child | viewport | Supply wrapper and iframe positions, visible viewport, inset, scroll mode, enable state, and monotonically increasing sequence. |
| Child to parent | navigate | Request scrolling to an allowlisted section. |
Every message carries channel, version, and session. Neither page reads the other page's DOM. No cross-origin CORS permission is required for postMessage.
Source
Parent bridge / Child bridge and placeholders / Position calculations / Report styles
These are reference files, not a drop-in production patch. Substitute the real elements, origins, header measurement, and section identifiers. Integrate with the existing height protocol rather than running two independent height controllers.
Limitations and release checks
The sidebar uses native sticky while the iframe is pinned. Cross-frame delivery still controls the report's internal scroll position, so report movement can trail the parent's scrollbar on busy devices. Entry and exit transitions also need real-device testing. This is an interim iframe integration, not a replacement for a native page.
Slow-scroll verification
Chrome wheel tests with 1.25-pixel inputs and direction reversals measured up to 2 pixels of sidebar jumping with the expanded iframe and transform bridge. The viewport-iframe candidate measured 0 pixels of jumping while both parent and report scroll positions advanced. This is a measured test result, not a guarantee for every browser or device.
The coordinate mapping assumes an unscaled, borderless iframe in the top-level scrolling page. Transformed embeds or nested scrolling containers need adjusted geometry.
Production checklist
- Test current Chrome, Edge, Firefox, and Safari, plus touch devices, zoom, keyboard navigation, and screen readers.
- Check content height after fonts, charts, responsive redraws, dynamic content, and consent banners change layout.
- Audit viewport-dependent tooltips, sticky chart parts, scroll-triggered animations, and keyboard interactions in the real content. They are not simulated here.
- Decide whether parent URL fragments, deep links, and Back/Forward navigation are needed. The demo does not implement them.
- Verify failure behavior, reloads, iframe replacement, malicious or malformed messages, and multiple embeds if supported.
- Retain explicit target origins, sender/source validation, finite bounded numbers, allowed section IDs, and appropriate CSP frame restrictions.