Embed YouTube videos without tracking cookies
@slashclone/privacy-friendly-youtube-embed
Embed YouTube videos on your site without loading anything from Google until the visitor presses Play or allows videos. A click-to-play placeholder with a local poster swaps in the youtube-nocookie.com player, with a keyboard-friendly button, no layout shift, CSP entries, and a Playwright test that proves no YouTube request happens first. React and HTML templates. Not legal advice.
SkillDevOpsv0.1.0
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1"use client";
- 3// A YouTube video that fetches nothing from Google until the visitor asks for it.
- 4// Before the click: a local placeholder with the title, the duration, and a real
- 5// play button. After the click (or once the visitor has allowed embedded videos):
- 6// the youtube-nocookie.com player in a 16:9 box that never changes size.
- 7//
- 8// Consent comes from your consent layer, not from this file. Pass `allowed` (the
- 9// visitor has granted the embedded-media category) and, if you like, `onAllowAll`
- 10// (grant that category from here). No dependencies besides React.
- 12import { useEffect, useRef, useState, type CSSProperties } from "react";
- 14const PLAYER_ORIGIN = "https://www.youtube-nocookie.com";
- 16/** The 11-character video id from a watch, share, shorts, live, or embed link, or null. */
- 17export function parseVideoId(input: string): string | null {
- 18 const ID = /^[A-Za-z0-9_-]{11}$/;
- 19 if (ID.test(input)) return input;
- 20 let u: URL;
- 21 try {
- 22 u = new URL(input);
- 23 } catch {
- 24 return null;
- 25 }
- 26 if (u.protocol !== "https:" && u.protocol !== "http:") return null;
- 27 const host = u.hostname.toLowerCase().replace(/^(www|m|music)\./, "");
- 28 let id: string | null = null;
- 29 if (host === "youtu.be") id = u.pathname.split("/")[1] ?? null;
- 30 else if (host === "youtube.com" || host === "youtube-nocookie.com") {
- 31 if (u.pathname === "/watch") id = u.searchParams.get("v");
- 32 else id = /^\/(?:embed|shorts|live|v)\/([^/?#]+)/.exec(u.pathname)?.[1] ?? null;
- 33 }
- 34 return id && ID.test(id) ? id : null;
- 35}
- 37export type YouTubeEmbedProps = {
- 38 /** A YouTube link or a bare video id. */
- 39 video: string;
- 40 /** Shown on the placeholder and used as the iframe title. Required for screen readers. */
- 41 title: string;
- 42 /** Human-readable length, for example "4:12". Shown on the placeholder if known. */
- 43 duration?: string;
- 44 /** A poster served from your own origin. Never a YouTube thumbnail URL. */
- 45 poster?: string;
- 46 /** Start time in whole seconds. */
- 47 start?: number;
- 48 /** Ask the player to show captions by default. The video must have them. */
- 49 captions?: boolean;
- 50 /** Link to a transcript on your own site, shown under the video. */
- 51 transcriptHref?: string;
- 52 /** True when the visitor has already allowed embedded videos. */
- 53 allowed: boolean;
- 54 /** Grants the embedded-media category in your consent layer. Optional. */
- 55 onAllowAll?: () => void;
- 56 className?: string;
- 57};
- 59export function YouTubeEmbed(props: YouTubeEmbedProps) {
- 60 const {
- 61 video,
- 62 title,
- 63 duration,
- 64 poster,
- 65 start,
- 66 captions,
- 67 transcriptHref,
- 68 allowed,
- 69 onAllowAll,
- 70 className,
- 71 } = props;
- 72 const id = parseVideoId(video);
- 73 const [clicked, setClicked] = useState(false);
- 74 const frame = useRef<HTMLIFrameElement>(null);
- 76 // After a click the button disappears. Move focus to the player so keyboard and
- 77 // screen reader users are not dropped at the top of the page.
- 78 useEffect(() => {
- 79 if (clicked) frame.current?.focus();
- 80 }, [clicked]);
- 82 if (!id) return null;
- 84 const box: CSSProperties = {
- 85 position: "relative",
- 86 width: "100%",
- 87 aspectRatio: "16 / 9",
- 88 overflow: "hidden",
- 89 background: "#111",
- 90 color: "#fff",
- 91 };
- 92 const fill: CSSProperties = {
- 93 position: "absolute",
- 94 inset: 0,
- 95 width: "100%",
- 96 height: "100%",
- 97 border: 0,
- 98 };
- 100 const params = new URLSearchParams({ rel: "0", playsinline: "1" });
- 101 if (clicked) params.set("autoplay", "1");
- 102 if (start && start > 0) params.set("start", String(Math.floor(start)));
- 103 if (captions) params.set("cc_load_policy", "1");
- 104 const src = `${PLAYER_ORIGIN}/embed/${id}?${params.toString()}`;
- 105 const watchHref = `https://www.youtube.com/watch?v=${id}${start ? `&t=${Math.floor(start)}s` : ""}`;
- 107 const transcript = transcriptHref ? (
- 108 <p style={{ margin: "0.5rem 0 0", fontSize: "0.875rem" }}>
- 109 <a href={transcriptHref}>Read the transcript</a>
- 110 </p>
- 111 ) : null;
- 113 if (allowed || clicked) {
- 114 return (
- 115 <figure className={className} style={{ margin: 0 }}>
- 116 <div style={box} data-yt-box="">
- 117 <iframe
- 118 ref={frame}
- 119 src={src}
- 120 title={title}
- 121 style={fill}
- 122 // Lazy only when nobody clicked: a clicked video should start now.
- 123 loading={clicked ? "eager" : "lazy"}
- 124 allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
- 125 allowFullScreen
- 126 // The player needs the referring origin to play. This sends the origin only.
- 127 referrerPolicy="strict-origin-when-cross-origin"
- 128 />
- 129 </div>
- 130 {transcript}
- 131 </figure>
- 132 );
- 133 }
- 135 return (
- 136 <figure className={className} style={{ margin: 0 }}>
- 137 <div style={box} data-yt-box="">
- 138 {poster ? (
- 139 <img
- 140 src={poster}
- 141 alt=""
- 142 style={{ ...fill, objectFit: "cover", opacity: 0.6 }}
- 143 loading="lazy"
- 144 decoding="async"
- 145 />
- 146 ) : null}
- 147 <div
- 148 style={{
- 149 ...fill,
- 150 display: "flex",
- 151 flexDirection: "column",
- 152 alignItems: "center",
- 153 justifyContent: "center",
- 154 gap: "0.75rem",
- 155 padding: "1rem",
- 156 textAlign: "center",
- 157 }}
- 158 >
- 159 <p style={{ margin: 0, fontWeight: 600 }}>
- 160 {title}
- 161 {duration ? <span style={{ fontWeight: 400 }}> ({duration})</span> : null}
- 162 </p>
- 163 <button
- 164 type="button"
- 165 onClick={() => setClicked(true)}
- 166 aria-label={`Play video: ${title}`}
- 167 style={{
- 168 font: "inherit",
- 169 padding: "0.6rem 1.2rem",
- 170 borderRadius: "999px",
- 171 cursor: "pointer",
- 172 }}
- 173 >
- 174 Play video
- 175 </button>
- 176 <p style={{ margin: 0, fontSize: "0.8125rem", maxWidth: "32rem" }}>
- 177 Playing loads the video from YouTube, which may set cookies.{" "}
- 178 <a
- 179 href={watchHref}
- 180 rel="noopener noreferrer"
- 181 target="_blank"
- 182 style={{ color: "inherit" }}
- 183 >
- 184 Watch on YouTube
- 185 </a>
- 186 {onAllowAll ? (
- 187 <>
- 188 {" or "}
- 189 <button
- 190 type="button"
- 191 onClick={onAllowAll}
- 192 style={{
- 193 font: "inherit",
- 194 color: "inherit",
- 195 background: "none",
- 196 border: 0,
- 197 padding: 0,
- 198 textDecoration: "underline",
- 199 cursor: "pointer",
- 200 }}
- 201 >
- 202 always allow videos
- 203 </button>
- 204 </>
- 205 ) : null}
- 206 .
- 207 </p>
- 208 </div>
- 209 </div>
- 210 {transcript}
- 211 </figure>
- 212 );
- 213}
