Back

Dynamic viewports with iframes

How to make an iframe behave like a real browser preview, not a cropped screenshot.

20245 min read

Iframes are fixed-size windows into another page. If you embed a desktop site into a 400px-wide container, you don't get a responsive preview. You get a cropped mess. The content renders at 400px and your carefully designed desktop layout is gone.

What you actually want is the iframe to render at, say, 1220px, but visually shrink to fit whatever container it's in. Like a browser DevTools device preview, but embeddable anywhere.

The trick is transform: scale(). Render the iframe at its full intended width, then scale it down to fit the parent container. The content thinks it's on a 1220px screen. The user sees it neatly fitting inside a card.

The core idea

You need two things: the iframe's actual width (the viewport you want to simulate) and the parent container's width (the space you have). Divide one by the other and you get your scale factor.

TSX
const scale = containerWidth / iframeWidth

A 1220px iframe inside a 610px container gets scale(0.5). The content renders at full size but displays at half size. Every breakpoint, every media query, every layout decision works exactly as if the user had a 1220px-wide browser.

The height needs adjusting too. When you scale an element down, its visual height shrinks, but the container doesn't know that. You need to set the iframe's height to containerHeight / scale so the scaled-down version fills the container correctly.

React implementation

TSX
interface ResponsiveIframeProps {
  src: string
  width: number
}

const ResponsiveIframe = ({ src, width }: ResponsiveIframeProps) => {
  const iframeRef = useRef<HTMLIFrameElement>(null)

  const resize = useCallback(() => {
    const iframe = iframeRef.current
    const wrapper = iframe?.parentElement

    if (!iframe || !wrapper) return

    const wrapperWidth = wrapper.offsetWidth
    const iframeWidth = iframe.offsetWidth

    const scale = wrapperWidth / iframeWidth
    iframe.style.transform = `scale(${scale})`

    const wrapperHeight = wrapper.offsetHeight
    const height = wrapperHeight / scale
    iframe.style.height = `${height}px`
  }, [])

  useEffect(() => {
    resize()
    window.addEventListener('resize', resize)
    return () => window.removeEventListener('resize', resize)
  }, [resize])

  return (
    <iframe
      ref={iframeRef}
      src={src}
      style={{ width, transformOrigin: 'top left' }}
    />
  )
}

The transformOrigin: 'top left' is important. Without it, the scaled iframe floats in the center of its original bounding box and everything looks broken.

Adding viewport controls

Once you have the component, switching between viewports is just changing the width prop. The iframe re-renders at the new width, the resize function recalculates the scale, and the content adapts.

TSX
const [viewportWidth, setViewportWidth] = useState<number>(1220)

return (
  <div>
    <ResponsiveIframe src="https://baraa.app" width={viewportWidth} />
    <div className="buttons">
      <button onClick={() => setViewportWidth(375)}>Mobile</button>
      <button onClick={() => setViewportWidth(768)}>Tablet</button>
      <button onClick={() => setViewportWidth(1220)}>Desktop</button>
    </div>
  </div>
)

Without React

Same concept, less ceremony. Set the iframe width in CSS, calculate the scale in JS, listen for resize events.

HTML
<div class="iframe-wrapper">
  <iframe id="iframe" src="https://baraa.app"></iframe>
</div>
JavaScript
const iframe = document.getElementById('iframe')

const resize = () => {
  const wrapper = iframe.parentElement

  if (!wrapper) return

  const wrapperWidth = wrapper.offsetWidth
  const iframeWidth = iframe.offsetWidth

  const scale = wrapperWidth / iframeWidth
  iframe.style.transform = `scale(${scale})`

  const wrapperHeight = wrapper.offsetHeight
  const height = wrapperHeight / scale
  iframe.style.height = `${height}px`
}

resize()
window.addEventListener('resize', resize)

The entire technique is one division and one CSS transform. No libraries, no build tools, no complexity. It just works.