docs
    agency-site-kit: Live device frames
    v0.11.1.md

    Live device frames

    A screenshot shows what a client's site looked like. A live frame shows the site itself: scrollable, current, obviously real. This page covers how to put a client's live site inside a device frame without slowing your page down, flashing a blank frame, or counting your visitors as the client's traffic.

    Start with the screenshot

    Render the screenshot first, on the server, and treat the live site as an upgrade on top of it. The screenshot is what crawlers, slow connections and visitors without JavaScript see, and it's what shows while the frame loads. hero_screenshots has one per device, and Sections and devices covers whether to show one laptop or three.

    Keep the live frame off anything above the fold that's your page's largest paint. The hero image should be a plain, server-rendered <img>; the iframe comes later.

    Mount the frame late, show it when it's ready

    Two rules make a live frame feel instant:

    1. Mount the iframe once your page is idle, not during load. A client's whole site loading in the middle of your first paint slows your page.
    2. Show it only once the framed site has painted. Until then, keep the screenshot in front. An iframe that appears before its page has drawn is a white box.

    A site running @sonordev/site-kit 6.5.0 or later tells you when it's painted. Inside a cross-origin frame, it posts { type: 'sonor:frame-ready', v: 1 } to the page that framed it, after its load event and first painted frame. isFrameReadyMessage from @sonordev/site-kit/portfolio/contract recognises it:

    'use client';
    
    import { useEffect, useRef, useState } from 'react';
    import { isFrameReadyMessage } from '@sonordev/site-kit/portfolio/contract';
    
    export function LiveFrame({ src, screenshot, title }: { src: string; screenshot: string; title: string }) {
      const frame = useRef<HTMLIFrameElement>(null);
      const [mounted, setMounted] = useState(false);
      const [ready, setReady] = useState(false);
    
      // 1. Mount the iframe once the page is idle.
      useEffect(() => {
        const mount = () => setMounted(true);
        if (typeof window.requestIdleCallback === 'function') {
          const id = window.requestIdleCallback(mount, { timeout: 3000 });
          return () => window.cancelIdleCallback(id);
        }
        const id = setTimeout(mount, 1500); // browsers without requestIdleCallback
        return () => clearTimeout(id);
      }, []);
    
      // 2. Reveal it when the framed site says it has painted.
      useEffect(() => {
        const onMessage = (event: MessageEvent) => {
          if (event.source !== frame.current?.contentWindow) return;
          if (isFrameReadyMessage(event.data)) setReady(true);
        };
        window.addEventListener('message', onMessage);
        return () => window.removeEventListener('message', onMessage);
      }, []);
    
      return (
        <div style={{ position: 'relative', aspectRatio: '16 / 10', overflow: 'hidden' }}>
          <img
            src={screenshot}
            alt=""
            style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
          />
          {mounted ? (
            <iframe
              ref={frame}
              src={src}
              title={title}
              tabIndex={-1}
              style={{
                position: 'absolute',
                inset: 0,
                width: '100%',
                height: '100%',
                border: 0,
                opacity: ready ? 1 : 0,
                transition: 'opacity 300ms',
              }}
            />
          ) : null}
        </div>
      );
    }

    Checking event.source against the iframe's window means only your own frame's message counts, not one from anything else on the page. A site that isn't on site-kit (or is on an older version) never sends the message, so its screenshot simply stays: nothing breaks.

    Showing one frame at desktop size inside a phone-sized box? Render the iframe at the device's real width and scale it down with a CSS transform, so the framed site lays itself out for that device.

    The framed site has to allow it

    Browsers only show a site inside your page if the site says your origin may frame it, with a Content-Security-Policy: frame-ancestors header. Sites on site-kit set that header from a default list. To add your agency's origin to a client's site, spread the default in its proxy.ts:

    // proxy.ts on the client's site
    import { createProxy, DEFAULT_FRAME_ANCESTORS } from '@sonordev/site-kit/proxy';
    
    export default createProxy({
      securityHeaders: {
        frameAncestors: [...DEFAULT_FRAME_ANCESTORS, 'https://youragency.com'],
      },
    });

    Don't also send X-Frame-Options: a leftover DENY or SAMEORIGIN blocks the frame even when frame-ancestors allows it. See site-kit's proxy docs for the details.

    Your visitors aren't the client's traffic

    When a site on site-kit runs inside a frame on another origin, it sends no analytics and shows no chat or popups by default. Everyone looking at your case study would otherwise show up in the client's numbers as a visit that came from your page, several times over when three device frames load the same site. So showcasing a client's live site doesn't touch their data. The details (and the opt-in for sites that are meant to be embedded) are in site-kit's analytics docs.

    A site that isn't on site-kit doesn't know it's framed. Its own analytics will count your visitors, so prefer screenshots for those.