A YouTube embed looks innocent enough.
You drop an iframe into a page, give it a src, and you're done, right?
Except you're not.
Google's Lighthouse documentation puts a full embedded YouTube player at around 540 KB. On a live page, it's closer to 1.3 MB. And the cost doesn't get amortized when you add another embed — two players can mean roughly twice the payload because the resources aren't shared.
HTTP Archive data referenced by web.dev puts another horrifying metric on it: the median YouTube embed occupies the browser's main thread for more than 1.7 seconds.
And here's the part that bothered me most: All of that happens even if nobody presses Play. 🤯
The Solution: The Facade Pattern
The fix isn't new. It's the facade pattern:
- Show a poster.
- Show a play button.
- Don't load the actual player.
- Only load it when the visitor clicks.
Tools like lite-youtube-embed have been doing this for years.
I wanted to bring this exact same concept into a WordPress Gutenberg block—but not just for YouTube, and definitely not by shipping an entire JavaScript framework to the browser just to replace a JPEG with an iframe.
That experiment became the Story Video Block (now live on the WP repo, source on GitHub).
Here are the most interesting architectural decisions and technical gotchas I ran into while building it. 👇
The Interactivity Dilemma
The actual interaction needed for a facade is tiny. Click. Swap in the embed. Play the video. That's basically it.
But traditional block solutions can be surprisingly expensive for such a small interaction:
- You could enqueue a classic
view.jsand manually handle event delegation and DOM manipulation. - You could ship React to the front end (some block plugins actually do this). You're essentially sending a UI framework to someone's phone so a poster image can turn into an iframe.
- You could use jQuery (please don't).
Enter WordPress's Interactivity API ⚡
Since WordPress 6.5, we have the Interactivity API. It gives blocks a declarative way to add client-side behavior. More importantly, the runtime is shared by all blocks on the page.
The opt-in is incredibly simple in your block.json:
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"viewScriptModule": "file:./view.js"
Notice one detail: viewScriptModule, not viewScript.
That registers the file through the Script Modules API and allows you to natively import the shared WP runtime:
import { store } from '@wordpress/interactivity';
Revelation: You Don't Need a Dynamic Block
This was my biggest misconception. Because almost every Interactivity API tutorial uses a dynamic block (render.php), I assumed they were mandatory. They aren't.
The directives are just HTML attributes. A static block's save() function can output them, and the frontend runtime will hydrate whatever it finds.
Here's how my static save() looks:
const blockProps = useBlockProps.save( {
className: clsx( {
[ `has-media-${ videoPosition }` ]: videoPosition,
[ `card-style-${ cardStyle }` ]: cardStyle,
} ),
style: {
'--story-video-block-bg': backgroundColor || undefined,
},
...( ! isFile
? {
'data-wp-interactive': 'create-block/story-video-block',
'data-wp-context': JSON.stringify( {
isPlaying: false,
embedUrl,
videoSrc: '',
} ),
}
: {} ),
} );
That JSON.stringify() is the whole server-state story for this block! The markup lives in post_content. There's no PHP render callback running on every request, yet each block instance gets its own scoped, reactive state.
The Performance Trick: The iframe with no src
You cannot render an iframe and just CSS hide it. If it has a src, the browser will load the player. Hidden doesn't mean unloaded.
So, the iframe is deliberately rendered without a src.
<div
className="story-video-block__video"
hidden
data-wp-bind--hidden="state.isNotPlaying"
>
<iframe
title={ heading || __( 'Video', 'story-video-block' ) }
data-wp-bind--src="context.videoSrc"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
</div>
Initially, context.videoSrc === ''. Because WP's data-wp-bind--* removes an attribute when its value is falsy, there is no src attribute at all. No request, no player.
Bonus UX win: Because the source is assigned inside the click handler, the browser treats playback as user-initiated. That means autoplay=1 actually works. One click, video starts. No double-clicking required.
Progressive Enhancement: The facade is a link 🔗
I didn't make the facade a <div>. JavaScript shouldn't be the only way to reach the video.
The facade is a real <a> tag pointing to the original video:
<a
href={ videoUrl }
target="_blank"
rel="noopener noreferrer"
className="story-video-block__facade"
data-wp-on--click="actions.play"
data-wp-bind--hidden="state.isPlaying"
>
Our Interactivity store then enhances it:
import { store, getContext } from '@wordpress/interactivity';
store( 'create-block/story-video-block', {
state: {
get isPlaying() { return getContext().isPlaying; },
get isNotPlaying() { return ! getContext().isPlaying; },
},
actions: {
play( event ) {
const context = getContext();
if ( context.isPlaying ) return;
// If no direct-embed URL (like TikTok), let the original <a> link work!
if ( ! context.embedUrl ) return;
event.preventDefault();
context.videoSrc = context.embedUrl;
context.isPlaying = true;
},
},
} );
This gives us graceful degradation:
- JS + Supported provider (YouTube/Vimeo): Plays inline seamlessly.
- JS + No direct embed (TikTok): Acts as a normal link.
- No JS enabled: Acts as a normal link.
🐛 A CSS Gotcha That Cost Me an Evening
The Interactivity API was working. The hidden attribute was toggling perfectly in the DOM. But the elements were still visible on the screen. Why?
Browsers have a default user-agent style:
[hidden] { display: none; }
But if your block's custom CSS applies an explicit display (like display: flex;), it overrides the low-specificity [hidden] rule.
The fix:
[hidden] {
display: none !important;
}
Pro-tip: If you're using data-wp-bind--hidden and nothing is happening visually, check your CSS specificity before ripping apart your JavaScript!
Why skip WordPress oEmbed on the frontend?
WordPress has robust oEmbed support. Why did I write manual regex provider detection for the frontend?
Because the /oembed/1.0/proxy endpoint requires edit_posts permissions. An anonymous site visitor gets a 401 Unauthorized.
Instead, the block parses the URL itself (supporting YouTube, Vimeo, Dailymotion, Twitch, mp4s, etc.) and constructs the embed URL string locally. Zero network requests just to figure out a URL we already know how to build.
Final Thoughts
Building this reminded me of a golden rule in web development: Don't make every visitor pay for a feature that only some visitors will use.
A video doesn't need to be a 1MB+ third-party application just because someone might press Play. A static Gutenberg block doesn't need to become dynamic just to hold state. And a tiny bit of interactivity doesn't require shipping a massive JS runtime.
If you're building blocks for WordPress, give the Interactivity API a shot for your next frontend component!
The Story Video Block is currently v0.1.0, requires WP 6.8+, and is GPL. If you want to check out the full code or contribute, PRs are welcome on GitHub!
Top comments (0)