# 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](https://sonor.dev/agency-site-kit/sections) 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:

```tsx
'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`:

```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](https://sonor.dev/site-kit/proxy) 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](https://sonor.dev/site-kit/analytics).

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.
