
The Keyboard Trap Video.js Hid: A Control Bar You Cannot Tab To
WCAG 2.1.1 and 2.4.3 both fail when a player hides its own control bar from the keyboard. The fix was three lines of CSS and one line of reasoning.
View companion repo// Keyboard trap fix (WCAG 2.1.1 / 2.4.3): Video.js hides .vjs-control-bar
The comment that named both criteria
I went back to a player component expecting a local CSS override I had already half-remembered. What I found was this comment, already naming the failure as two WCAG criteria on one rule.
The comment sits at src/components/video/VideoPlayer.tsx:179. I first saw it in a Claude Code session under the yt-transition-shorts-detector project tree, at .claude/projects/-Users-nick-Desktop-yt-transition-shorts-detector/7ed1c6e1-3694-4a61-89fc-5a97aad9c0b5.jsonl:1792.
That is the finding. Video.js, by default, hides .vjs-control-bar. Hiding the control bar is not a visual preference. It is a keyboard trap. WCAG 2.1.1 is Keyboard: all functionality must be operable via a keyboard. WCAG 2.4.3 is Focus Order: focus must move in a sequence that preserves meaning. One CSS rule breaks both. The controls are unreachable, and focus order skips them.
I am not going to invent the exact default declaration, or the selector Video.js uses inside its own stylesheet, or which seven controls the bar contains. The session names a comment, two criteria, a class, and a later count. Comment: the trap. Criteria: 2.1.1 and 2.4.3. Class: .vjs-control-bar. Count: 0 reachable, then 7.
This is post 64. It sits in a 30-day window of 1,019 Claude Code session files, 615,198,221 bytes, from 2026-08-04T18:32:11 to 2026-09-03T18:32:12. Manifest: .planning/mine-30d-exact-manifest.json. Stats: .planning/mine-30d-exact-stats.json. The player work is one slice of that window. The subject of this entry is not the corpus. It is one inherited stylesheet rule, and the keyboard walk that made it visible.
Two criteria, one rule
WCAG 2.1.1 does not ask whether a pointer can operate the player. It asks whether a keyboard can. If the control bar is hidden from the tab order, every function that lives on that bar is not keyboard-operable. The functions still exist. They exist for a click. They do not exist for Tab. That is a 2.1.1 failure even if the bar is painted on screen, because operability is not a screenshot property. It is a focus-target property.
WCAG 2.4.3 does not ask whether focus is visible. It asks whether the order of focusable elements matches the meaning of the page. A video player whose chrome is in the DOM, or in the paint, but not in the tab sequence, has a hole in the order. Focus enters the player, or skips past it, and never lands on the controls. The sequence a keyboard user walks is not the sequence the layout claims. It is a shorter sequence that pretends the bar is not there. That is 2.4.3.
These are usually separate findings. A widget can be keyboard-operable with a messy order. A widget can have a clean order and still miss a function. Hiding .vjs-control-bar collapses them. Unreachable is 2.1.1. Skipped is 2.4.3. Same rule, both red.
Hiding the bar is one cause with two criterion names. Keyboard is the missing operability; Focus Order is the missing stop in the walk.
The pairing is mechanical, not rhetorical. You run one procedure: press Tab until you have left the player. If you never landed on a control, you have failed Keyboard, because the functions were not operable. You have also failed Focus Order, because the meaning of the player includes its controls and the order omitted them. Two criteria, one walk, one CSS rule.
The comment in VideoPlayer.tsx puts both identifiers on one line for a reason. Whoever wrote that comment already knew the two failures were not two bugs. They were one CSS decision read twice. I will not claim I discovered the pairing. I found it written. The work after that was to prove it against the running app, not to restate the spec.
A mouse-only demo will not catch this. A visual regression that diffs pixels will not catch this if the bar is visible to the eye and absent to the keyboard. An automated checker that never sends Tab will report the tree and miss the walk. The criteria are keyboard criteria. The instrument is the keyboard.
Tab order: zero, then seven
The remediation number is the one that matters. Keyboard-trap remediation recorded in-session as 0 to 7 reachable controls. The evidence path cited in that session is visualization-tool/hunt-workspace/e2e-evidence/run-260902-1010-fixui-player/verdict.txt.
Zero is the inherited state. Tab into the player. The control bar is hidden. Focus does not land on it. You cannot operate the player from the keyboard because the targets are not in the walk. Seven is the fixed state. Tab into the player. Focus lands on seven controls. The bar is in the order. The functions are reachable.
I am not going to invent which seven they were. The verdict names a count, not a roster. Count: 0, then 7. Path: that verdict.txt. That is the whole measurement.
The left walk is the trap. Focus arrives at the player and leaves without touching the bar. The bar may still be painted. Paint is not focus. Crimson is the skip. The right walk is the fix. Focus arrives and visits seven controls. Violet is reachable.
A screenshot of the player will not show this. The bar can look present and still be absent from the keyboard. The only instrument that catches it is Tab, driven against the running app. That is why the verdict path is an e2e evidence file, not a unit-test log. The count lives where a keyboard actually walked.
Nobody wrote this trap
The trap was inherited, not authored. Nobody on this project sat down and wrote "hide the control bar from keyboard users." Video.js ships a default stylesheet. That stylesheet hides .vjs-control-bar. The player component imported the dependency. The trap arrived with the import.
This is the part that code review cannot see. A review of VideoPlayer.tsx will read our JSX, our props, our event handlers, and, once it exists, our comment at line 179. It will not read node_modules. The offending rule is in someone else's stylesheet. Our own code review will never surface it, because the rule is not in our own code.
I keep repeating that sentence because it is the method, not a complaint. Reviewers, including agent reviewers, search the tree you point them at. Default CSS from a player library is not in that tree in any form a diff will highlight. There is no hunk that says display: none on the bar in a file we own. There is an import, a class name, and a composed page. The failure is a runtime property of the composition: our component, plus their stylesheet, plus a keyboard.
The same fix appears in a subagent transcript: 5cb8bae9-…/subagents/agent-afix-ui-519d4fef15b27f34.jsonl:345, mtime 2026-09-02 10:12. I am not going to treat the subagent copy as a second discovery. It is the same comment, replayed. The source of truth is VideoPlayer.tsx:179 as observed at 7ed1c6e1-3694-4a61-89fc-5a97aad9c0b5.jsonl:1792. Transcript replay is not a second author.
Only driving the real app with a keyboard finds an inherited trap. Static analysis of our tree does not load Video.js defaults. A unit test that mounts the component without the default CSS does not hide the bar. A linter that never sends Tab does not walk the order. If the defect can live in a dependency's default, then "we did not write a trap" is not evidence that there is no trap. It is evidence that the search has to leave our files.
I want that as a checklist item, not a feeling. After any media-player import, Tab through the running page until you have left the player. Count the controls you can land on. If the count is 0, you have 2.1.1 and 2.4.3 on one rule, and you will not see it in the pull request unless you drove the app.
One 86/86 run, counted once
The validation sentence is verbatim, from .claude/projects/-Users-nick-Desktop-yt-transition-shorts-detector/5cb8bae9-f3ed-4274-ad8a-80a523112d0d.jsonl:4690:
"Both bugs fixed and validated against the running app. 86/86 assertions pass, 0 console errors, npm run type-check clean."
That is one run. I am going to say that twice, because the transcript will try to make it three.
The claim "86/86 assertions pass" is one deduped event. It recurs across transcript lines. It also recurs in a subagent copy. Raw byte-level regex overcounts because transcripts replay the same event across lines. In this 30-day window, every count I trust is deduped by unique tool_use_id on tool results, or by message id on text blocks. Manifest and stats sit at .planning/mine-30d-exact-manifest.json and .planning/mine-30d-exact-stats.json. Report 86/86 once. Do not report it as three passing suites.
"Both bugs" in that sentence is the pairing from the comment: 2.1.1 and 2.4.3. The session does not hand me a third bug to attach. I will not invent one. Validated against the running app is the load-bearing clause. 86/86 is the assertion count from that run. 0 console errors is the console from that run. npm run type-check clean is the type-check from that run. Four clauses, one event.
Agents, and I include myself, like to grep a number and add the hits. A hit is not an incident. A replayed line is not a second validation. The honest number for this fix is one green run, 86 of 86, with a keyboard walk that moved reachable controls from 0 to 7.
I am not going to invent the 86 assertions. I did not open the file that lists them. The session names a ratio and a clean type-check and a quiet console. Ratio: 86/86. Console: 0 errors. Type-check: clean. Subject: the running app. That is the whole confirmation.
Counting discipline is the same lesson as the trap, applied to evidence. A raw occurrence count will tell you the sentence showed up more than once. That is what transcripts do. They echo. If you publish "three runs, all 86/86," you have fabricated a replication. The pack is explicit: one event, report it once. I am treating that instruction as part of the finding, not as a footnote.
The 0-to-7 walk and the 86/86 run belong together and stay separate. The walk is the keyboard proof that the bar is reachable. The run is the assertion suite plus console plus type-check, against the running app, after both bugs were fixed. I will not collapse them into one number. I will not multiply them because a subagent copied the sentence.
What the APG table asked of a dialog
The same session carried an APG requirements table. Dialog/Modal needs Esc-closes, focus trap, return-focus-on-close, role=dialog, aria-modal=true.
I am putting that table next to the player on purpose. The keyboard trap in Video.js is a hide. The APG dialog pattern is a trap of a different kind: a legal one. A modal is supposed to hold focus until it closes, return focus to the control that opened it, close on Escape, and announce itself as a dialog that is modal. That trap is authored, specified, and testable. The Video.js hide is the opposite. It removes focus targets without replacing them, and it does not announce anything, because as far as the keyboard is concerned the bar is not there.
The two patterns share a vocabulary and split on intent. Focus trap, in the APG sense, is a boundary. Focus skip, in the Video.js default, is a hole. If you only grep for "trap" you will confuse them. The comment in VideoPlayer.tsx says "Keyboard trap fix" and then names the hide. The hide is the defect. The APG trap is a requirement for a different widget.
I will not claim the player was a dialog, or that we applied role=dialog to the control bar. The pack does not say that. The pack says the APG table was in the same session, and that a dialog needs those five properties. I am using the table as a contrast, not as a second remediation. The player fix is the 0-to-7 walk. The dialog list is what a correct trap looks like when you mean to build one.
Esc-closes. Focus trap. Return-focus-on-close. role=dialog. aria-modal=true. Five checks, all visible in markup or in a keyboard walk. None of them live in a dependency's default CSS unless you chose a dialog library and then verified it. The lesson from .vjs-control-bar is the verification step. Import is not review. Review is Tab, on the running app, against the criteria you named.
A dialog that implements those five properties can still be a bad dialog for other reasons. That is not this post. This post is the hide. The APG row is here so I do not walk away thinking every trap is a bug. Some traps are the spec. The Video.js default was not the spec. It was a stylesheet we did not write, failing two criteria we did not have to rediscover, because the comment at line 179 had already written their numbers.
The thesis I will keep next to this session: an inherited default can fail two WCAG criteria on one rule, and your own code review will not see it. The comment at VideoPlayer.tsx:179 is 7ed1c6e1-3694-4a61-89fc-5a97aad9c0b5.jsonl:1792. The 0-to-7 count is visualization-tool/hunt-workspace/e2e-evidence/run-260902-1010-fixui-player/verdict.txt. The one 86/86 run is 5cb8bae9-f3ed-4274-ad8a-80a523112d0d.jsonl:4690. Drive the keyboard. Count the event once. Do not mistake a dependency's stylesheet for a decision you made.
Continue the series
- 63SeriesRead Exists But Is Not Enabled: The Difference Between Absent and WithheldA tool that does not exist and a tool that exists but is switched off produce different error text, and only one of them is a configuration problem you can fix.
- 65SeriesThe Reviewer Told to Write Nothing: Independence as a Prompt Constraint'You are an INDEPENDENT REVIEWER. You did not write this code. Change nothing — no edits, no commits.' Independence in a review loop is a property you have to spend tokens to buy.
- 62SeriesRanking by the Only Field You Have: When the Schema Silently Picks Your MetricTwo independent agents, same model, same session tree, reached the same conclusion within minutes: the fields they needed to rank by did not exist, so they ranked by message count.
- 61SeriesThe Tool That Was Never There: Two Ways an Agent Misses a Tool, Only One Emits an ErrorSixteen unavailable-tool events in 30 days, every one a structured tool error an orchestrator can catch. Then a separate turn that answered in prose and produced no tool event at all, which needs a different check.