Juha-Matti Santala
Community Builder. Dreamer. Adventurer.

Custom cookie consent for video embeds

Embedding media into a website comes with an annoyance as those embeds set cookies and track your readers. But sometimes it can be worth it, so I wanted to add a way to deal with embeds, starting with Youtube, on my blog in a way that doesn’t involve annoying cookie consent popups and gives the reader full control over each video.

What does this look like for the end user?

Let’s say I want to add Apollo Robbins’ great TED Talk about the art of misdirection to my blog. First option is to do what I just did here: link to it and tell readers to go watch it.

But if I want to let people watch it while keeping the blog post open, I can embed it into my blog post in Notion and when it gets rendered in my site, it looks like this:

Screenshot of this blog showing article Youtube embed example with a video thumbnail of Apollo Robbins’ TED talk and a play button on top of it

It will show the thumbnail of the video with a Play button.

If you click anywhere on the picture, it will show a cookie consent form in place of the video:

Screenshot of cookie consent form when clicking Play on a video embed, informing the user that Youtube sets cookies for embedded videos and offering options to accept, decline or watch in Youtube.

The reader has the option to Accept, in which case the video will be embedded on the page, Youtube sets its own cookies and you can watch the video. If they decline or close from the x button, it will got back to the previous view and if they click Watch on Youtube, it will open the video in Youtube on a new tab.

Screenshot of Youtube video The art of misdirection by Apollo Robbins embedded and playing in the blog

You can test it out yourself with this actual embed of this video (and if don’t like Youtube, you can watch this great and entertaining talk in TED.com).

How about Youtube’s nocookie embeds?

Youtube offers an option to use youtube-nocookie.com/embed/[ID] URL in the embed but it doesn’t actually block all cookies. What it apparently does it that it just doesn’t connect the video with a logged in Youtube account.

Jason Grigsby has written about it as well as Per Axbom, if you want to know more about the details. If you are relying on the nocookie option because of its deceptive name, you should consider alternative options to offer your readers better ways.

How is it built?

Pre-build rendering

When I add a link to Youtube on a new line in my blog post in Notion, Notion asks if I want to do a Youtube embed. An embed gets stored in a video block in Notion’s data model.

So I started by creating a custom renderer for video blocks:

const embedRenderer = createBlockRenderer("video", async (data, renderer) => {
  const URL = data.video.external.url;
  if (URL.includes("youtube")) {
    let description = data.video.caption.plain_text;
    let videoId = URL.split("=")[1];
    let imageName = `${videoId}.jpg`;
    let imageUrl = `https://i3.ytimg.com/vi/${videoId}/hqdefault.jpg`;

		// Custom housekeeping relevant to my blog omitted

    return `<div class="embed-container" data-url="${URL}">
      <button class="youtube-thumbnail" aria-label="Play Youtube video ${description}">
      <img src="${imageFilePath}" alt="Youtube thumbnail for ${description}" />
      <img src="/assets/img/play-button.png" class="play-button" alt="Play">
      </button>
      <div class="consent-form hide">
        <p class="consent-header"><strong>Consent for 3rd party cookies</strong><button class="close-consent" aria-label="Close consent form">x</button></p>
        <p><a href="https://policies.google.com/technologies/cookies" target=_blank>Youtube sets cookies</a> when you watch embedded videos. By clicking <strong>Accept</strong>, you consent to these cookies being set. Alternatively, you can click <strong>Watch on Youtube</strong> to watch it there or <strong>Decline</strong> to prevent video from being shown.</p>
  
        <div class="consent-nav">
          <button class="consent-confirm">Accept</button>
          <button class="close-consent">Decline</button>
          <a href="${URL}" target=_blank>Watch on Youtube</a>
        </div>
      </div>
    </div> `;
  } else {
    console.error(`Support for ${URL} is not yet added.`);
  }
});

There are a couple of things that happen here:

First, I check if the URL has youtube in it and if not, I print an error to the console reminding myself that I’m embedding something that I haven’t build support for yet. Since I’m the solo creator and developer in this project, I can get away with a bit of a shortcut like this. For any system that would have different people writing blog posts, I’d make it way more robust. But since I know when I’m adding a new type of embed, it’s just there for a reminder and a safeguard that nothing wonky goes into my blog.

Second, I get the thumbnail image so I can show it to the user.

Finally, I create a HTML string that has a few things. It has the URL stored as a data-attribute (so we’re not sending any requests to Youtube on page load) and have a play button that gets positioned on top of the image to signify to users that it is a video that can be clicked.

I don’t have access to the actual Youtube video data though so I’m using the same trick I do with images: I store the video name in the caption of the embed and extract it from there.

I tell the user what this cookie consent form is there for and give them three options: Accept the cookies and embed video, decline and not embed the video or open the Youtube video in youtube.com on a new tab.

This rendering happens on pre-build time when I download my blog into my local Eleventy project.

Client runtime

When the user reaches my blog and decides to watch a video, I run some Javascript to show the consent form and deal with it accordingly.

let embedTemplate = `<iframe 
  width="640"
  height="480"
  src="[url]" 
  title="YouTube video player" 
  frameborder="0" 
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
  allowfullscreen></iframe>`;

Object.values(document.querySelectorAll(".youtube-thumbnail")).forEach(
  (embed) => {
    embed.addEventListener("click", (ev) => {
      ev.preventDefault();

      let container = ev.target.parentElement.parentElement;
      let image = container.children[0];
      let form = container.children[1];

      image.classList.add("hide");
      form.classList.remove("hide");

      Object.values(
        container.querySelectorAll(":scope .close-consent")
      ).forEach((btn) =>
        btn.addEventListener("click", (ev) => {
          const container = ev.target.parentElement.parentElement;
          container.classList.add("hide");
          image.classList.remove("hide");
        })
      );
      container
        .querySelector(":scope .consent-confirm")
        .addEventListener("click", (ev) => {
          ev.preventDefault();
          container.innerHTML = embedTemplate
            .replace("[url]", container.dataset.url)
            .replace("watch?v=", "embed/");
        });
    });
  }
);

For each Youtube “faux” embed, I add a click handler that hides the image and shows the consent form. I then add event listeners to the buttons to accept or decline the cookies.

If the user accepts cookies, I then switch the element’s HTML into a Youtube embed iframe and replace the [url] placeholder with the one stored in the parent div.

At this point, the first network requests fire to youtube.com and Youtube sets its own cookies.

This way, no cookies get set and no requests are sent to Youtube before the user accepts it.

I don’t have a mechanism set to accept all Youtube embed cookies, I figured it is better for the user to have improved privacy rather than a slightly improved user experience. And I don’t plan to have a lot of embedded videos at any given post so user might very rarely actually run into this.

Wrap up

I’m happy to have this first iteration done. It will likely improve over time as I come up with better ideas and see it in action a bit more.

Big thanks to Lucia for the inspiration and sparring on this during our blog writing coffee session!