Drupal's default oEmbed template is extremely sparse, and some accessibility checkers don't like that.
The missing elements:
- A
<title>tag. - Language attributes on the
<html>tag.
Fortunately these are pretty easy to deal with. The first step is to make the required information available to the Twig template via template_preprocess_media_oembed_iframe():
/**
* Implements template_preprocess_media_oembed_iframe().
*
* Add a head title and language to oEmbed iframes.
*/
function THEME_preprocess_media_oembed_iframe(array &$variables) {
if (!isset($variables['head_title']) || (!$variables['head_title'])) {
// Set a default better-than-nothing title.
$placeholders = ['@site_name' => \Drupal::config('system.site')->get('name')];
$variables['head_title'] = t('@site_name Media', $placeholders);
// If a resource is available, and it has a title, use that instead.
if (isset($variables['resource']) && ($resource_title = $variables['resource']->getTitle())) {
$variables['head_title'] = $resource_title;
}
}
if (!isset($variables['html_attributes'])) {
// Make language attributes available.
$variables['html_attributes'] = new Attribute();
if ($language = \Drupal::languageManager()->getCurrentLanguage()) {
if ($lang = $language->getId()) {
$variables['html_attributes']->setAttribute('lang', $lang);
}
if ($dir = $language->getDirection()) {
$variables['html_attributes']->setAttribute('dir', $dir);
}
}
}
}Note that in order to create attributes the Attribute class has to be available at the top:
use Drupal\Core\Template\Attribute;With that in place, it's just a matter of providing a media-oembed-iframe.html.twig Twig template that puts the elements in the right places:
<!DOCTYPE html>
<html{{ html_attributes }}>
<head>
<title>{{ head_title }}</title>
<css-placeholder token="{{ placeholder_token }}">
</head>
<body style="margin: 0">
{{ media|raw }}
</body>
</html>This is just the default template with the new elements added—ordinarily I'd go out of my way to remove the inline CSS, but in this case I'm not confident enough in my knowledge of all the contexts where this might appear to start messing around with that, so it gets to stay.
Video URL Parameters
Update 2026-07-10
I recently implemented an embedded Vimeo video for a client that was required to autoplay and loop—the video URL was provided with these parameters appended:
badge=0&autopause=0&player_id=0&app_id=58479&autoplay=1&muted=1&loop=1
When I added it as a media entity with source “remote video”, however, all of these parameters were stripped from the iframe output; as a result, the behaviours that they were supposed to trigger were absent (no autoplaying, no looping, etc.). Here's a separate implementation of template_preprocess_media_oembed_iframe() that enables a few additional parameters to be passed in the URL:
/**
* Implements hook_node_links_alter().
*
* Preserve embedded video parameters, and remove the hardcoded dimensions.
*/
function MODULE_preprocess_media_oembed_iframe(&$variables) {
$allowed_parameters = [
'app_id',
'autopause',
'autoplay',
'badge',
'h',
'loop',
'mute',
'muted',
'player_id',
];
if ((isset($variables['media']))
&& ($original_iframe = $variables['media']->__toString())) {
libxml_use_internal_errors(TRUE);
$dom = new DOMDocument();
$dom->loadHTML($original_iframe, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$iframes = $dom->getElementsByTagName('iframe');
if (($iframes->length > 0)
&& ($iframe = $iframes->item(0))
&& ($iframe_src = $iframe->getAttribute('src'))
&& ($iframe_src_base = strtok($iframe_src, '?'))) {
$query = \Drupal::entityTypeManager()->getStorage('media')?->getQuery()
->condition('field_media_oembed_video.value', $iframe_src_base . '%', 'LIKE')
->accessCheck(FALSE)
->range(0, 1);
$ids = $query->execute();
if (($id = reset($ids))
&& ($media = Media::load($id))
&& ($media->hasField('field_media_oembed_video'))
&& ($media_url = $media->get('field_media_oembed_video')?->getString())
&& ($media_query_string = parse_url($media_url, PHP_URL_QUERY))
&& ($iframe_src_query_string = parse_url($iframe_src, PHP_URL_QUERY))) {
parse_str($media_query_string, $media_query_parameters);
parse_str($iframe_src_query_string, $iframe_src_query_parameters);
$query_parameters = array_intersect_key(array_merge($media_query_parameters, $iframe_src_query_parameters), array_flip($allowed_parameters));
$iframe->setAttribute('src', $iframe_src_base . '?' . http_build_query($query_parameters));
$iframe->removeAttribute('height');
$iframe->removeAttribute('width');
$variables['media'] = IFrameMarkup::create($dom->saveHTML($iframe));
}
}
}
}A couple of dependency declarations required here:
use Drupal\media\Entity\Media;
use Drupal\media\IFrameMarkup;And a few other things to notice:
- The adjustment starts with a list of allowed parameters: nothing that doesn't appear in this list will be allowed through. Drupal's implementation presumably strips unrecognized parameters at least partly for security reasons, so it seems unwise to allow just anything to be passed through.
- The iframe
srcattribute is isolated by converting the iframe markup to a string and then parsing it using PHP'sDOMDocumentclass. It would be nice if the source were available in a way that didn't require getting quite so dirty, but I couldn't find it anywhere in$variables. There might be a path to avoid this by loading the media entity fresh and just building the source from scratch, but I want to adjust the output as little as possible (e.g., any parameters that aren't stripped should be used as-is from the original URL—this is why, when the two arrays of parameters are combined usingarray_intersect_key(), the out-of-the-box parameters overwrite any new added parameters with matching array keys). - Drupal's default behaviour is to strip parameters from the output, but they remain in the saved “Remote video URL”, so they can be retrieved from the database using a query that matches the URL starting from its beginning and ending with a wildcard.
- The default fixed dimensions of the iframe are removed. I can't remember the last time I embedded a video that wasn't ultimately required to appear at a wide range of sizes depending on the platform, viewport, etc., so I'm not sure why the dimensions are there by default. Using CSS to resize the element doesn't seem to cause any problems either way, but since the iframe markup is being rebuilt anyway it seems a little cleaner not to include irrelevant hard-coded dimensions in the tag.
The two template_preprocess_media_oembed_iframe() implementations here can obviously be combined. I sometimes struggle to decide what code belongs semantically in a custom module vs. what belongs in a custom theme. I think I'd be inclined to put both of these in a module, except that anything that involves Twig templates gets an extra shove in the theme direction for two reasons:
- No need to implement
hook_theme()to register a template from a module. - No concern that the template is going to be overwritten by the active theme, or, potentially more perniciously, a base theme that the active theme depends on.
So implementing them in two different places also seems like a reasonable option.