Installation
npx shadcn@latest add https://www.deltacomponents.dev/r/embed.json
Usage
1import { Embed } from "@/components/ui/embed";23<Embed url="https://www.youtube.com/watch?v=eVjP9Vnh2xE" />
YouTube
A watch, share or shorts link becomes a thumbnail and a button. The
player itself is close to a megabyte of third-party script, so on Android and
desktop it only loads once someone presses play, from youtube-nocookie.com;
on iOS the player is mounted eagerly once the block nears the viewport, since
a tap on a cross-origin iframe there won't otherwise land inside it and start
the video with sound.
Once the video is playing, a tap on a touch screen is ours, not YouTube's: it toggles play and pause over the player's message API. Left to the player, a tap brings up its chrome, and the title bar across the top of a phone-height embed is a link into the YouTube app, so trying to pause opened the app instead. The strip along the bottom stays YouTube's, for scrubbing, volume and fullscreen. A mouse can aim, so on desktop the player keeps every click.
The corners are square by default; pass className="rounded-lg" for a radius.
Spotify
A track or episode link gets the compact player; an album, playlist or show
gets the tall one. Either way the iframe's src is only set once the player
is about to scroll into view, so a page of link drops doesn't front-load every
player on it at once.
Link
Anything else valid becomes a card. The URL alone is enough: with the preview
route installed, LinkPreview fetches the page's own title, description and
image and fills the card in. Pass title, description or image yourself
to skip the fetch, and endpoint={null} to turn it off outright. Without the
route — or before the fetch resolves — the card stays the hostname with the
address beneath it, which is what a pasted link with nothing else settles for.
Image
Any link whose path ends in an image extension. It is drawn with the
registry's Image, so it enlarges on a click, and ImageEmbed
takes everything that component does: a caption, width and height,
bleed, zoomable={false} for a plain picture. Installing Embed brings Image
with it.
Video
An .mp4, .webm or .mov plays where it sits, with the browser's own
controls, held back the same way the Spotify player is.
Pass a poster when you have one: it shows at once and costs nothing until
someone presses play. Without it the opening frame stands in, which Safari
only paints when asked for a moment just past zero, so the component asks.
The corners are square by default here too; pass className="rounded-lg" for a
radius.
From untrusted text
Embed is the one part here that doesn't trust its input. The others
(YouTubeEmbed, ImageEmbed and the rest) take whatever a developer hands
them; Embed takes a raw string, such as a pasted line or a comment body, and
decides what it is with classifyUrl.
Two rules do the work. Only an absolute http(s) URL counts: a root- or
protocol-relative path would load from the reader's own origin, with their
cookies. And image or video is decided from the URL's path alone, so a query
string can't pass an endpoint off as one.
This is the same classifier the Editor's Extensions example pairs with a small CodeMirror widget, so a link alone on a line in the editor becomes exactly what it becomes here.
The preview route
A browser can't read another site's HTML — cross-origin fetch from the page
itself is blocked, so filling in a link's title, description and image needs
a server that can. Embed and LinkPreview call /api/link-preview for
that; installing the component installs the route alongside it, at
app/api/link-preview/route.ts.
It refuses:
- Anything but
http/https, a non-default port, or a URL carrying a username or password. - Any address the DNS lookup resolves to that isn't on the public internet — loopback, the private ranges, link-local (where cloud metadata endpoints live), and their IPv6 equivalents — checked at connection time, so a hostname can't pass the check and then resolve somewhere else.
- More than 3 redirects, each re-checked the same way as the original URL.
- A response that isn't
text/htmlorapplication/xhtml+xml— the fields come back empty rather than erroring.
And it limits itself to a 5-second timeout and 512 KiB of the response body,
since a preview's tags are in the head.
Two caveats. It needs the Node runtime, not the edge — dns.lookup's custom
resolver depends on it. And rate limiting is deployment-specific — a CDN,
middleware, an API gateway — so it's left to you.
