WordPress YouTube Shortcode That Loads Video on Click

A normal YouTube embed in WordPress loads the full player, its scripts and YouTube cookies on every page view, even when nobody presses play. On a page with a few videos that is the heaviest part of the page.

This shortcode shows only the video thumbnail with a play button. The player is created when the visitor clicks, from youtube-nocookie.com, and it starts playing right away. Use it in any post, page or Shortcode block:

  • [yt id=0-wlEr669CY] (the old usage keeps working)
  • [yt id="0-wlEr669CY" title="Product tour" start="30"]

The snippet

<?php
/**
 * [yt id="VIDEO_ID" title="Video title" start="30"]
 * Prints a thumbnail with a play button. The youtube-nocookie.com player
 * is only loaded when the visitor clicks it.
 */
add_shortcode( 'yt', 'dpc_youtube_facade_shortcode' );

function dpc_youtube_facade_shortcode( $atts ): string {
	$atts = shortcode_atts(
		array(
			'id'    => '',
			'title' => __( 'YouTube video', 'dpc' ),
			'start' => 0,
		),
		$atts,
		'yt'
	);

	$id = trim( (string) $atts['id'] );
	if ( ! preg_match( '/^[A-Za-z0-9_-]{11}$/', $id ) ) {
		// Only people who can edit content see why nothing rendered.
		return current_user_can( 'edit_posts' )
			? '<p class="dpc-yt-error">' . esc_html__( 'YouTube shortcode: the id must be the 11-character video ID.', 'dpc' ) . '</p>'
			: '';
	}

	$title = sanitize_text_field( $atts['title'] );
	$start = absint( $atts['start'] );

	dpc_youtube_facade_assets();

	return sprintf(
		'<div class="dpc-yt"><button type="button" class="dpc-yt__play" data-dpc-yt-id="%1$s" data-dpc-yt-start="%2$d" data-dpc-yt-title="%3$s" title="%4$s" aria-label="%4$s"><img src="%5$s" alt="" width="480" height="360" loading="lazy" decoding="async"><span class="dpc-yt__icon" aria-hidden="true"></span></button></div>',
		esc_attr( $id ),
		$start,
		esc_attr( $title ),
		/* translators: %s: video title. */
		esc_attr( sprintf( __( 'Play video: %s', 'dpc' ), $title ) ),
		esc_url( 'https://i.ytimg.com/vi/' . $id . '/hqdefault.jpg' )
	);
}

/** CSS and JS once per page, and only on pages that contain a video. */
function dpc_youtube_facade_assets(): void {
	if ( wp_script_is( 'dpc-yt', 'enqueued' ) ) {
		return;
	}

	$css = '.dpc-yt{position:relative;aspect-ratio:16/9;overflow:hidden;background:#000}'
		. '.dpc-yt__play,.dpc-yt iframe{position:absolute;inset:0;width:100%;height:100%;margin:0;padding:0;border:0}'
		. '.dpc-yt__play{cursor:pointer;background:#000}'
		. '.dpc-yt__play img{display:block;width:100%;height:100%;object-fit:cover}'
		. '.dpc-yt__icon{position:absolute;top:50%;left:50%;width:68px;height:48px;border-radius:12px;background:rgba(0,0,0,.75);transform:translate(-50%,-50%);transition:background .2s}'
		. '.dpc-yt__icon::before{content:"";position:absolute;top:50%;left:50%;border-style:solid;border-width:11px 0 11px 19px;border-color:transparent transparent transparent #fff;transform:translate(-40%,-50%)}'
		. '.dpc-yt__play:hover .dpc-yt__icon,.dpc-yt__play:focus-visible .dpc-yt__icon{background:#c00}'
		. '.dpc-yt__play:focus-visible{outline:3px solid #fff;outline-offset:-6px}';

	$js = <<<'JS'
document.addEventListener('click', function (event) {
	var button = event.target.closest('.dpc-yt__play');
	if (!button) {
		return;
	}
	var id = button.getAttribute('data-dpc-yt-id') || '';
	if (!/^[A-Za-z0-9_-]{11}$/.test(id)) {
		return;
	}
	var start = parseInt(button.getAttribute('data-dpc-yt-start'), 10) || 0;
	var iframe = document.createElement('iframe');
	iframe.src = 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1' + (start > 0 ? '&start=' + start : '');
	iframe.title = button.getAttribute('data-dpc-yt-title') || '';
	iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
	iframe.allowFullscreen = true;
	iframe.referrerPolicy = 'strict-origin-when-cross-origin';
	button.replaceWith(iframe);
	iframe.focus();
});
JS;

	// Handles without a file: WordPress prints only the inline code.
	wp_register_style( 'dpc-yt', false, array(), null );
	wp_add_inline_style( 'dpc-yt', $css );
	wp_enqueue_style( 'dpc-yt' );

	wp_register_script( 'dpc-yt', false, array(), null, array( 'in_footer' => true ) );
	wp_add_inline_script( 'dpc-yt', $js );
	wp_enqueue_script( 'dpc-yt' );
}

Add it with Scripts Organizer

In Scripts Organizer go to Code Blocks → Add New and give the block a name. Set Trigger location to Everywhere, tick PHP under Script location, paste the code into the PHP editor and publish. Leave Action/Hook empty.

Everything lives in that one PHP block on purpose. Scripts Organizer prints Header and Footer code only with the Conditions trigger, so a separate CSS or JS part would either need its own conditions or load on every page. Here the shortcode enqueues its small inline style and script itself, once per page and only on pages that actually contain a video. current_user_can() is only called inside the shortcode, after WordPress has loaded the user.

The same code also works unchanged in a small plugin or an mu-plugin.

How it works

  • The id must be an 11-character YouTube ID (A-Z, a-z, 0-9, _, -). Anything else renders nothing for visitors, and a short notice for users who can edit posts. Every attribute is escaped on output, and extract() is gone.
  • The facade is a real <button> with an aria-label and title, so keyboard and screen reader users can start the video. The thumbnail has loading="lazy" and explicit dimensions.
  • One click listener on the document handles every video on the page. It checks the ID again, builds the youtube-nocookie.com/embed/ iframe with autoplay=1 and the optional start, and replaces the button with it. No jQuery.
  • CSS and JS are registered as handles without a file, so WordPress prints only the inline code: the style wherever styles are still being printed, the script in the footer. wp_script_is() makes sure they are added once, however many videos the page has.
  • aspect-ratio: 16/9 keeps the box responsive, so the separate wrapper CSS from the old version is no longer needed.

Customise it

  • Sharper thumbnails: replace hqdefault.jpg with maxresdefault.jpg. Not every video has that size, so test yours.
  • Other colours: change #c00 (hover) and rgba(0,0,0,.75) (button) in $css.
  • If another plugin already registers a [yt] shortcode, change the first argument of add_shortcode() and the third argument of shortcode_atts().

Gotchas

  • The thumbnail still comes from i.ytimg.com, so the visitor’s browser contacts YouTube before the click. For strict consent setups, host the thumbnail yourself.
  • Browsers may ignore autoplay with sound. On a click it is usually allowed, but some mobile browsers need a second tap.
  • youtube-nocookie.com avoids tracking cookies until playback starts; it is not a full consent solution.
  • Page builders that render shortcodes in an editor preview will show the facade there too, which is expected.

In the Scripts Organizer library Import it with one click from Scripts Organizer.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.