YT.Player Replacing a div Can Leave a Black Screen
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Initialising YT.Player by passing it a div element shows nothing on screen until the div is replaced by an iframe, and depending on timing it can stay black and never start playing.
The short version
Initialising the YouTube IFrame API by handing it a div element, as in new window.YT.Player(containerRef.current, {...}), shows nothing on screen until the API replaces that div with an iframe internally. Depending on load timing it stayed black and playback never started. Rendering an <iframe> with a src directly in JSX and attaching the YT API to it afterwards by element id makes the video appear without waiting for the API to load.
What it looks like
In a component playing training videos as YouTube embeds, the video area stayed black and playback never began. Two hours earlier the same day we had added an onError handler as a fallback for a failed embed.
onError: () => {
setEmbedError(true)
stopPolling()
},
Setting embedError to true was supposed to switch to an external “Watch on YouTube” link, but this black screen never entered that branch. We had added a fallback, and the black screen actually occurring was outside what it covered.
Why
Initialisation took the div element by ref and passed it straight to the YT.Player constructor.
function initPlayer() {
if (!containerRef.current) return
playerRef.current = new window.YT.Player(containerRef.current, {
videoId: youtubeId!,
height: '100%',
width: '100%',
playerVars: { modestbranding: 1, rel: 0, fs: 1 },
events: {
onReady: (e) => { ... },
onStateChange: (e) => { ... },
onError: () => {
setEmbedError(true)
stopPolling()
},
},
})
}
The caller invoked initPlayer immediately if the API script was already loaded, and otherwise through the onYouTubeIframeAPIReady callback.
if (window.YT?.Player) {
initPlayer()
} else {
const prev = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => { prev?.(); initPlayer() }
if (!document.getElementById('yt-iframe-api')) {
const s = document.createElement('script')
s.id = 'yt-iframe-api'
s.src = 'https://www.youtube.com/iframe_api'
document.head.appendChild(s)
}
}
Meanwhile, what was rendered on screen was only an empty div.
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0,
borderRadius: '0.75rem', overflow: 'hidden', background: '#000' }}>
<div ref={containerRef}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }} />
</div>
In this arrangement, three stages sit between load and a visible video: “the API script loads”, “onYouTubeIframeAPIReady fires”, “YT.Player() replaces the div with an iframe”. background: '#000' is displayed throughout, so if that replacement does not proceed as expected the screen simply stays black.
onError is only called when the YouTube IFrame API actually returns an error code such as playback being unavailable. This black screen, stuck because the div was never replaced by an iframe, is not the kind of failure that returns an error code from the API. So the embedError fallback added just two hours earlier never detected it once.
Fixing it
We stopped passing the container div by ref and moved to rendering an <iframe> with a src directly in JSX.
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0,
borderRadius: '0.75rem', overflow: 'hidden', background: '#000' }}>
<iframe
id={iframeId}
src={`https://www.youtube.com/embed/${youtubeId}?enablejsapi=1&rel=0&modestbranding=1`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
/>
</div>
YT.Player’s initialisation shrank to attaching, by id string, to the iframe already in the DOM.
// The iframe is already in the DOM, so attach by passing its id
function attachPlayer() {
playerRef.current = new window.YT.Player(iframeId, {
events: {
onReady: (e) => {
totalSecRef.current = e.target.getDuration() || durationMinutes * 60
},
onStateChange: (e) => { ... },
onError: () => stopPolling(),
},
})
}
To match, the YT.Player type definition, whose first parameter had been DOM-element-only, was widened to accept an id string.
Player: new (el: string | HTMLElement, opts: YTPlayerOptions) => YTPlayer
Because the iframe’s src is settled by React’s render, the video itself displays without waiting for the YouTube IFrame API script to load or its callback to complete. The pollTimer and heartbeatTimer logic — seek prevention, progress reporting every 30 seconds, marking completion at 90% watched — is unchanged and still runs once attachPlayer has bound the API to the iframe.
Preventing a repeat
The embedError fallback assumed only failures where the YouTube IFrame API explicitly returns an error code. “Initialisation never completes” was not in its scope, and we noticed the same symptom two hours after adding it, resolving it by changing the initialisation approach rather than fixing the fallback.
With display no longer depending on the API finishing initialisation, whether the video appears is decided solely by whether the browser can load the iframe’s src. The timing of the YouTube IFrame API load and onYouTubeIframeAPIReady still affects how long until progress tracking is active, but it is no longer on the path where the video itself sits black.
よくある質問
Q1Why does passing a div sometimes give a black screen?
With new window.YT.Player(containerRef.current, {...}), all that is on screen is an empty div until the YouTube IFrame API has loaded, the callback has run and the div has been replaced by an iframe. Depending on load timing that replacement did not complete, and it stayed black without playing.
Q2Didn't the onError handler added two hours earlier catch it?
It did not. onError only fires when the YouTube IFrame API returns an error code, such as playback being unavailable. A black screen stuck because the div never became an iframe is not a failure the API reports. The branch setting embedError to true never ran, and the fallback link never appeared.
Q3What was changed in the fix?
We stopped passing the container div by ref and render an <iframe> with a src directly in JSX. YT.Player's initialisation was reduced to attaching by the iframe's id string, and the type definition changed from el: HTMLElement to el: string | HTMLElement.
Q4What difference does rendering the iframe directly make?
The video appears as soon as React renders, with no wait for the YouTube IFrame API script or its callback. Progress tracking and control through onReady and onStateChange still take effect once the API attaches, but the stuck black screen is a display-side problem and it goes away.
Q5Does it affect the seek-prevention or progress-reporting logic?
It does not. The seek-prevention and progress-reporting logic built on pollTimer and heartbeatTimer still runs after attachPlayer is called; the change only moved YT.Player's initialisation target from the div to the iframe's id.
確認した環境
- Next.js 16.2.10 / React 19.2.4
- Fixed on 2026-07-30
この記事の根拠
- TypeScriptファイル 137〜163行目コミット ebc340e
- TypeScriptファイル 234〜238行目コミット ebc340e
- TypeScriptファイル 5〜9行目コミット 26784ba
- TypeScriptファイル 124〜146行目コミット 26784ba
- TypeScriptファイル 184〜196行目コミット 26784ba
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。