---
title: Embed
description: The embed plugin adds Notion's embed block, a provider's player (YouTube, Vimeo, Loom, Figma, CodePen, Spotify) in a sandboxed frame, from an allowlist you can extend.
icon: app-window
---

`createEmbedPlugin()` adds Notion's embed block: an "Embed a link" panel (your `empty` snippet, `createMediaEmptyPlugin({ empty })`) until the block has a link, then the provider's player in a sandboxed `iframe`, with an editable caption. It is not a default plugin: list it.

```svelte title="Editor.svelte" check
<script lang="ts">
	import { Edytor, createEmbedPlugin } from 'edytor';
</script>

<Edytor plugins={[createEmbedPlugin()]} />
```

A [slash menu](/docs/plugins/slash-menu) then lists "Embed" under `Media`, and pasting a link a provider plays on an empty line offers to embed it ([Pasting a link on an empty line](/docs/editor/clipboard#pasting-a-link-on-an-empty-line)).

## Options

`createEmbedPlugin(options)` takes an `EmbedPluginOptions`:

| Prop | Type | Default | Description |
| - | - | - | - |
| `providers?` | `readonly EmbedProvider[]` | - | The allowlist. Default EMBED_PROVIDERS. A link none of them plays shows no frame. |
| `menu?` | `Snippet<[UrlPasteController]>` | - | The menu a URL pasted on an empty line opens (Link, Embed, Bookmark); the view's one menu takes the first menu its embed and bookmark plugins pass; without one, the link stays and nothing shows. Keep its ARIA with {@attach controller.popup} and {@attach controller.option(index)}. See Menus and handles. |
| `fallback?` | `Snippet<[EmbedFallbackPayload]>` | - | What an embed shows for a link no allowed provider plays (a collaborator may store any), replacing the plain link. The first embed plugin listed in a view gives it. |

`fallback` receives an `EmbedFallbackPayload`: `block` (`undefined` in a suggestion's preview), `url` (an `http(s)` link) and `link`, the attachment of your `a` (`target="_blank"`, `rel="noopener noreferrer nofollow"`), as a menu's row takes its own; render its `href={embed.url}` yourself. It is never a frame: only an allowlisted provider plays.

```svelte title="Embeds.svelte" check
<script lang="ts">
	import { Edytor, createEmbedPlugin, type EmbedFallbackPayload } from 'edytor';

	const embeds = createEmbedPlugin({ fallback });
</script>

{#snippet fallback(embed: EmbedFallbackPayload)}
	<a class="unplayable" href={embed.url} {@attach embed.link}>Open {new URL(embed.url).host}</a>
{/snippet}

<Edytor plugins={[embeds]} />
```

## Providers

Only an allowlisted provider plays. `EMBED_PROVIDERS` holds:

| Provider | Links                                                                   | Player                                       |
| -------- | ----------------------------------------------------------------------- | -------------------------------------------- |
| YouTube  | `youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`, `/embed/…`, `/live/…` | `www.youtube-nocookie.com/embed/<id>`, with a `t` start time |
| Vimeo    | `vimeo.com/<id>`, `player.vimeo.com/video/<id>`                          | `player.vimeo.com/video/<id>`                |
| Loom     | `loom.com/share/<id>`, `loom.com/embed/<id>`                             | `www.loom.com/embed/<id>`                    |
| Figma    | `figma.com/design/…` (also `file`, `proto`, `board`, `slides`, `deck`)   | `www.figma.com/embed?url=…`                  |
| CodePen  | `codepen.io/<user>/pen/<id>`                                             | `codepen.io/<user>/embed/<id>`               |
| Spotify  | `open.spotify.com/track/<id>` (also albums, playlists, episodes, shows, artists) | `open.spotify.com/embed/<kind>/<id>`         |

A provider is an `EmbedProvider`: a `name` (the frame's `title`), `embed(url)` that answers the player URL for a link of the provider or `null`, and an optional `aspectRatio` (CSS, default `16 / 9`) or fixed `height` in pixels. Add your own after the bundled ones:

```ts title="embeds.ts" check
import { createEmbedPlugin, EMBED_PROVIDERS, type EmbedProvider } from 'edytor';

const miro: EmbedProvider = {
	name: 'Miro',
	embed: (url) => {
		const [kind, board] = url.pathname.split('/').filter(Boolean);
		return url.hostname === 'miro.com' && kind === 'app' && /^[\w=-]+$/.test(board ?? '')
			? `https://miro.com/app/live-embed/${board}/`
			: null;
	}
};

export const embed = createEmbedPlugin({ providers: [...EMBED_PROVIDERS, miro] });
```

Match the host exactly (never a suffix or a substring) and check every id you copy into the player URL. `embedSourceOf(link, providers?)` answers `{ src, provider }` for a link, or `null`.

## Security

- The frame's `src` is always the player URL the provider derives from the stored link, never the stored value: a collaborator or a pasted document can store any `url`, and a link no provider plays renders a plain link instead of a frame.
- A player URL must be `https:` on another origin than your page's; a provider answering anything else is refused. The frame has `allow-scripts` and `allow-same-origin`, so a player on your page's origin, or on one that can script it, could remove its own sandbox: a custom provider must never answer one.
- A suggestion's preview has no view to read the allowlist from, so it never plays: an embed there shows its link.
- The stored link must be an absolute `http:`/`https:` URL with a host, read by the link sanitizer's rules (control characters stripped, as the browser's URL parser does): `safeWebUrl(value)`.
- The frame has `sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation"` (`EMBED_SANDBOX`): the player runs in its own origin and may open its pages in a new tab, but never navigates your page, opens dialogs, downloads or submits forms. It never has `srcdoc`.
- `allow="fullscreen; picture-in-picture; encrypted-media; clipboard-write"` (`EMBED_ALLOW`), `referrerpolicy="strict-origin-when-cross-origin"` (YouTube refuses a player without its embedder's origin) and `loading="lazy"`.

## The kind

| Kind    | Element  | Data              | Preset                                                           |
| ------- | -------- | ----------------- | ---------------------------------------------------------------- |
| `embed` | `figure` | `{ url: string }` | Embed (⧉, group `Media`, keywords `iframe`, `youtube`, `vimeo`…) |

The block is void like the [image](/docs/plugins/image): it takes no children and is never merged, and its only text is the caption. The command id is `block.embed`. Its role is in `defaultSemantics`, so the room and headless documents treat it as void too.

In the empty block, `empty.embed()` (the demo's link field, "Paste the link…") embeds a link a provider plays as one data write, and sets `failed` for another (the demo says "No embed provider plays that link."). A readonly view's controller is `readonly`: show a passive placeholder.

## Clipboard

- Copying exports `<figure><iframe src="<player>" title="…" sandbox="…" allowfullscreen></iframe><figcaption>caption</figcaption></figure>`, or a link for a URL no provider plays; the plain text is the link, then the caption.
- Pasting HTML imports an `iframe` whose `src` a provider plays (bare, or in a `figure` whose `figcaption` becomes the caption) as an embed of that `src`. Any other `iframe` is dropped, and nothing is imported from an `iframe` when the plugin is not listed.

## Styling

| Selector                              | Matches                                         |
| ------------------------------------- | ----------------------------------------------- |
| `[data-edytor-type='embed']`          | The `figure`                                    |
| `[data-edytor-embed]`                 | The frame's wrapper (its aspect ratio or height, which the plugin sets) |
| `[data-edytor-embed-unsupported]`     | The link (your `fallback`) shown for a URL no provider plays |
| `[data-edytor-media-empty]`           | Where the empty state (your `empty` snippet) renders |

The [Notion theme](/docs/customization/styling#notion-theme) styles the blocks (the plugin keeps only the frame's size: its provider's aspect ratio or height); the empty state's look is your markup's (the demo's `MediaEmpty.svelte`, shared by every media block: [bookmark](/docs/plugins/bookmark), [file](/docs/plugins/file), [video](/docs/plugins/video), [audio](/docs/plugins/audio)).
