Skip to content
SlashClone

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

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.