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
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.
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.
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
| Prop | Type | Default | What it does |
|---|---|---|---|
title | string | none | Names the iframe and heads the placeholder. Required, because a frame needs a name. |
ratio | string | "16 / 9" | Frame shape as a CSS aspect-ratio. |
provider | string | none | Who serves the content. Defaults to the host name of src, such as youtube.com. |
description | ReactNode | none | The line under the title. Defaults to the site it loads from and that it may set cookies. |
poster | ReactNode | none | Art behind the placeholder, such as a thumbnail you host yourself. |
action | string | "Load content" | Text of the load button, such as Load video or Show map. |
active | boolean | none | Loaded, when you control it, for example from a consent you saved. |
defaultActive | boolean | false | Loaded on first render, when you do not control it. |
onActivate | () => void | none | Called when the person chooses to load the content. |
...rest | ComponentProps<"iframe"> | none | Every native iframe attribute, including src, srcDoc, allow, sandbox and ref. className and style go to the frame box. |
Anatomy
| root | .eq-embed | Holds the ratio; data-active once loaded. |
|---|---|---|
| placeholder | .eq-embed-placeholder | Shown until load is chosen. |
| poster | .eq-embed-poster | |
| card | .eq-embed-card | Title, note and button. |
| title | .eq-embed-title | |
| note | .eq-embed-note | Says where the content comes from. |
| load | .eq-embed-load | Described by the title and the note. |
| frame | .eq-embed-frame | The iframe, rendered only once active. |
Keyboard
| EnterSpace | On 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;
}
}
}