Media

Embed

A responsive frame for third-party embeds that makes no request until someone chooses to load it.

Built on
<iframe>aspect-ratioContainer queries
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
2.3 kB
Built from
Icon

Launch board

Loads content from Whiteboard, which may set cookies.

Code
"use client";

import { Embed } from "@/components/equip/embed";

/* The board is written into the frame with srcDoc, so even after loading nothing is fetched. */
const board = `<body style="margin:0;font:13px system-ui;color-scheme:light dark;display:grid;grid-template-columns:repeat(3,1fr);gap:8px;padding:10px;height:100vh;box-sizing:border-box">
${[["To do", "Press kit", "Pricing page"], ["Doing", "Demo video"], ["Done", "Beta invites"]]
  .map(([column, ...cards]) => `<section><b>${column}</b>${cards.map((card) => `<p style="border:1px solid GrayText;border-radius:6px;padding:6px;margin:6px 0">${card}</p>`).join("")}</section>`)
  .join("")}</body>`;

export default function Demo() {
  return <Embed title="Launch board" provider="Whiteboard" srcDoc={board} style={{ width: 216 }} />;
}

Examples

Video from another site

No iframe exists until the button is pressed, so the other site gets no request; then the frame loads and takes focus. Here srcDoc stands in for the provider's URL.

Product tour, two minutes

Loads content from videos.example.com, which may set cookies.

Code
"use client";

import { Embed } from "@/components/equip/embed";

/* Stands in for a video page from another site. With a real src the note names its host. */
const player = `<body style="margin:0;height:100vh;display:grid;place-items:center;color-scheme:light dark;font:600 14px system-ui">Product tour would play here</body>`;

function Thumb() {
  return (
    <svg viewBox="0 0 160 90" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
      <rect width="160" height="90" style={{ fill: "color-mix(in srgb, var(--eq-chart-3) 35%, var(--eq-surface))" }} />
      <circle cx="120" cy="30" r="18" style={{ fill: "color-mix(in srgb, var(--eq-chart-1) 70%, var(--eq-surface))" }} />
      <path d="M0 70 Q50 40 90 62 T160 56 V90 H0Z" style={{ fill: "color-mix(in srgb, var(--eq-chart-3) 70%, var(--eq-ink))" }} />
    </svg>
  );
}

export default function Example() {
  return (
    <Embed
      title="Product tour, two minutes"
      provider="videos.example.com"
      srcDoc={player}
      allow="autoplay; encrypted-media; picture-in-picture"
      poster={<Thumb />}
      action="Load video"
      style={{ maxWidth: 520 }}
    />
  );
}

Remembered consent, square map

Control active from a saved choice so every map on the site loads once someone has agreed; the frame here is srcDoc, so nothing is fetched.

Map of our office

Loads content from Maps, which may set cookies.

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Embed } from "@/components/equip/embed";

const map = `<body style="margin:0;height:100vh;color-scheme:light dark;background:repeating-linear-gradient(0deg,transparent 0 23px,GrayText 23px 24px),repeating-linear-gradient(90deg,transparent 0 23px,GrayText 23px 24px);display:grid;place-items:center;font:600 13px system-ui"><span style="background:Canvas;padding:4px 8px;border-radius:6px">Office, 14 Quay Street</span></body>`;

export default function Example() {
  const [allowed, setAllowed] = useState(false);
  return (
    <div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 320 }}>
      <Embed
        title="Map of our office"
        ratio="1"
        provider="Maps"
        srcDoc={map}
        active={allowed}
        onActivate={() => setAllowed(true)}
        action="Show map"
      />
      <Button size="s" variant="ghost" disabled={!allowed} onClick={() => setAllowed(false)}>
        Forget my choice
      </Button>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
titlestringnoneNames the iframe and heads the placeholder. Required, because a frame needs a name.
ratiostring"16 / 9"Frame shape as a CSS aspect-ratio.
providerstringnoneWho serves the content. Defaults to the host name of src, such as youtube.com.
descriptionReactNodenoneThe line under the title. Defaults to the site it loads from and that it may set cookies.
posterReactNodenoneArt behind the placeholder, such as a thumbnail you host yourself.
actionstring"Load content"Text of the load button, such as Load video or Show map.
activebooleannoneLoaded, when you control it, for example from a consent you saved.
defaultActivebooleanfalseLoaded on first render, when you do not control it.
onActivate() => voidnoneCalled when the person chooses to load the content.
...restComponentProps<"iframe">noneEvery native iframe attribute, including src, srcDoc, allow, sandbox and ref. className and style go to the frame box.

Anatomy

root.eq-embedHolds the ratio; data-active once loaded.
placeholder.eq-embed-placeholderShown until load is chosen.
poster.eq-embed-poster
card.eq-embed-cardTitle, note and button.
title.eq-embed-title
note.eq-embed-noteSays where the content comes from.
load.eq-embed-loadDescribed by the title and the note.
frame.eq-embed-frameThe iframe, rendered only once active.

Keyboard

EnterSpaceOn the load button, loads the content and moves focus into the frame.

Notes

  • Until it is activated there is no iframe at all, so the other site receives no request, sets no cookies and runs no script.
  • The load button is described by the title and the note, so assistive tech hears what will load and from where.
  • Focus moves into the frame only when the person pressed load, never when active is set for them.
  • Defaults to referrerPolicy strict-origin-when-cross-origin and allowFullScreen; pass your own to change them.
  • It is a size container, so it takes the full width of its box rather than sizing to its content; set a width or max-width to limit it.
  • Keep poster images on your own host; a thumbnail from the provider would make the request this part avoids.
  • The demo and examples use srcDoc for the loaded content, so they fetch nothing.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/embed.tsx118 lines
"use client";

import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react";
import { Icon } from "./icon";
import "./embed.css";

export type EmbedProps = Omit<ComponentProps<"iframe">, "title"> & {
  /** Names the frame and heads the placeholder, e.g. "Q3 launch video". */
  title: string;
  /** Frame shape as a CSS aspect-ratio. */
  ratio?: string;
  /** Who serves the content, e.g. "YouTube". Defaults to the host name of src. */
  provider?: string;
  /** The line under the title. Defaults to which site it loads from and that it may set cookies. */
  description?: ReactNode;
  /** Art behind the placeholder, such as a thumbnail you host yourself. */
  poster?: ReactNode;
  /** Text of the load button. */
  action?: string;
  /** Loaded, when you control it, for example from a saved consent. */
  active?: boolean;
  /** Loaded on first render, when you do not control it. */
  defaultActive?: boolean;
  /** Called when the person chooses to load the content. */
  onActivate?: () => void;
};

function host(src: string | undefined): string | undefined {
  if (!src) return undefined;
  try {
    return new URL(src).hostname.replace(/^www\./, "");
  } catch {
    return undefined;
  }
}

export function Embed({
  title,
  ratio = "16 / 9",
  provider,
  description,
  poster,
  action = "Load content",
  active,
  defaultActive = false,
  onActivate,
  className,
  style,
  src,
  ...rest
}: EmbedProps) {
  const id = useId();
  const frame = useRef<HTMLIFrameElement>(null);
  const chosen = useRef(false);
  const [inner, setInner] = useState(defaultActive);
  const on = active ?? inner;
  const from = provider ?? host(src);
  const line = description ?? (from ? `Loads content from ${from}, which may set cookies.` : "Loads content from another site.");

  // Focus follows the person into the frame they chose to load, not one loaded for them.
  useEffect(() => {
    if (on && chosen.current) {
      chosen.current = false;
      frame.current?.focus();
    }
  }, [on]);

  function activate() {
    chosen.current = true;
    if (active === undefined) setInner(true);
    onActivate?.();
  }

  return (
    <div
      className={["eq-embed", className].filter(Boolean).join(" ")}
      style={{ ...style, "--_ratio": ratio } as CSSProperties}
      data-active={on || undefined}
    >
      {on ? (
        <iframe
          referrerPolicy="strict-origin-when-cross-origin"
          allowFullScreen
          {...rest}
          ref={frame}
          src={src}
          title={title}
          className="eq-embed-frame"
        />
      ) : (
        <div className="eq-embed-placeholder">
          {poster != null && (
            <div className="eq-embed-poster" aria-hidden="true">
              {poster}
            </div>
          )}
          <div className="eq-embed-card">
            <p className="eq-embed-title" id={`${id}-title`}>
              {title}
            </p>
            <p className="eq-embed-note" id={`${id}-note`}>
              {line}
            </p>
            <button
              type="button"
              className="eq-embed-load"
              aria-describedby={`${id}-title ${id}-note`}
              onClick={activate}
            >
              <Icon name="play" size={16} />
              {action}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
components/equip/embed.css145 lines
@layer equip {
  .eq-embed {
    position: relative;
    box-sizing: border-box;
    display: grid;
    /* A size container has no width of its own, so it fills its box unless given one. */
    width: 100%;
    min-width: 0;
    aspect-ratio: var(--_ratio, 16 / 9);
    overflow: hidden;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.35 var(--eq-font);
    container-type: inline-size;
  }

  .eq-embed-frame {
    display: block;
    width: 100%;
    height: 100%;
    border: 0;
    animation: eq-embed-in var(--eq-dur) var(--eq-ease);
  }

  @keyframes eq-embed-in {
    from {
      opacity: 0;
    }
  }

  .eq-embed-frame:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  .eq-embed-placeholder {
    position: relative;
    display: grid;
    place-items: center;
    min-height: 0;
    padding: var(--eq-space-3);
    background:
      repeating-linear-gradient(135deg, var(--eq-tint) 0 8px, transparent 8px 16px),
      var(--eq-surface);
  }

  .eq-embed-poster {
    position: absolute;
    inset: 0;
  }

  .eq-embed-poster > :is(img, svg, picture, canvas) {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .eq-embed-card {
    position: relative;
    display: grid;
    gap: var(--eq-space-1);
    justify-items: center;
    max-width: 36ch;
    padding: var(--eq-space-3) var(--eq-space-4);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: color-mix(in srgb, var(--eq-raised) 92%, transparent);
    -webkit-backdrop-filter: var(--eq-blur);
    backdrop-filter: var(--eq-blur);
    text-align: center;
  }

  .eq-embed-title {
    margin: 0;
    font-weight: var(--eq-weight-strong);
    font-size: var(--eq-text-m);
  }

  .eq-embed-note {
    margin: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
  }

  .eq-embed-load {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    height: var(--eq-h-s);
    margin: var(--eq-space-1) 0 0;
    padding: 0 var(--eq-space-3) 0 var(--eq-space-2);
    border: 0;
    border-radius: var(--eq-radius-s);
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
    white-space: nowrap;
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-embed-load:hover {
      background: color-mix(in srgb, var(--eq-solid) 88%, var(--eq-solid-ink));
    }
  }

  .eq-embed-load:active {
    scale: var(--eq-press);
  }

  .eq-embed-load:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* A small frame drops the card's box and keeps the words short. */
  @container (max-width: 300px) {
    .eq-embed-placeholder {
      padding: var(--eq-space-2);
    }

    .eq-embed-card {
      padding: var(--eq-space-2);
      gap: 2px;
    }

    .eq-embed-title {
      font-size: var(--eq-text-s);
    }

    .eq-embed-note {
      display: -webkit-box;
      overflow: hidden;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 2;
      line-clamp: 2;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index