Every provider hub on CogniPrep used to end with this, on 24 pages:
{/* Hidden AI-crawlable content - not rendered visually */}
<div aria-hidden="true" className="sr-only">
<h2>About CogniPrep Arctic Shores Practice</h2>
<p>
CogniPrep provides authentic simulations of all 14 Arctic Shores psychometric
assessment games. Each practice game replicates the real mechanics, timing and
scoring used by employers...
</p>
<ul>
<li>14 Arctic Shores games available to practice</li>
...
</ul>
</div>
Three hundred words of accurate, useful product description, sitting on the page where it had the best possible chance of ranking, and not one human being could read it.
The two classes cancel each other into nothing
This is worth spelling out because the two attributes do different things and the combination is not what either one alone means.
sr-only is a utility class that pulls an element off screen while keeping it in the accessibility tree. It is a real accessibility tool, and it has real uses: the label on an icon-only button, a "skip to content" link, a table caption that would be noise to a sighted reader. Content that is sr-only is hidden from sighted users and read out by screen readers.
aria-hidden="true" does the opposite: it removes the element from the accessibility tree entirely. Content that is aria-hidden is visible to sighted users and invisible to screen readers.
Put them together and you get a block that sighted users cannot see and assistive technology will not announce. The set of humans who can read it is empty. What remains is a parser reading raw HTML, which is exactly the audience the comment above it names.
That is not a clever SEO trick, it is the definition of hidden text in Google's spam policies. The policy is not about intent and it does not care that every sentence in the block was true. It is about text that is in the markup for a machine and out of the markup for a person, and this block was as pure an example as you can write.
We deleted the hiding, not the text
The obvious fix is to delete the block. That would have been wrong, because the content was good. It named what the practice covers, what is on the free tier, what a score report contains, and which employers use the provider. Every one of those is a thing a visitor arriving from a search result actually wants to know, and it was the only place on the page that answered them in prose.
So the diff on all 24 pages is two attributes out and a set of classes in:
{/* About the practice on this hub. Rendered on purpose: it used to be a
screen-reader-only block, and hidden text is a spam-policy risk. */}
<div className="mb-16">
<h2 className="mb-6 text-2xl font-bold md:text-3xl">
About CogniPrep Arctic Shores Practice
</h2>
<p className="text-muted-foreground mb-4 text-sm leading-relaxed">
...
</p>
<ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm leading-relaxed">
...
</ul>
</div>
It also moved. The block used to be the last thing in the main container, after the call to action, because nothing that renders needs to care where invisible markup sits. Now it sits before the call to action, which is the order that makes sense for something a person reads: describe the thing, then ask for the click.
Making it visible changes what you are allowed to write
This is the part that took longer than the refactor, and it is the real argument for never having crawler-only copy in the first place.
Hidden text has no editor. Nobody reviews it, nobody notices when it goes stale, and a number in it can be wrong for a year without a single complaint, because complaints come from readers and it has none. The moment the same paragraph renders, every claim in it is a claim you are making to a customer, and the bar moves.
Two examples from that one page.
The counts have to be real. The block says "all 14 Arctic Shores games" and the bullet list repeats "14 Arctic Shores games available to practice". The call to action directly underneath it has always derived the same number from the game library:
Sign up for free to try {freeGames.length} games, or upgrade to access all{' '}
{asGames.length} Arctic Shores games with unlimited practice sessions.
Two numbers, one hand typed and one derived, now sitting a few hundred pixels apart on a page anyone can load. They agree today. The hand typed one is a hostage to the next provider expansion, and it is visible disagreement rather than invisible rot, which is the entire improvement.
The names have to be the names users see. The bullet list says the free tier includes "Balloon, Arrows and Sequence". In the library those three games have ids balloon, arrows and memory. The third one is displayed as Sequence everywhere in the product. Copy written for a crawler can happily say memory, because no reader is going to go looking for a game by that name and fail to find it. Copy on a page a candidate reads has to use the string that is printed on the card they are about to click.
The test that replaces the tactic
The rule we ended up with is short:
If the copy is not good enough to show a visitor, it is not good enough to show a crawler. If it is good enough to show a crawler, show the visitor.
There is no third category. "Content for the machines" describes either content that is too weak to publish, in which case rewrite it, or content that is genuinely useful and has been hidden by mistake, in which case render it. We had the second kind and had convinced ourselves it was the first.
A useful side effect of applying the rule is that sr-only gets its meaning back. The Arctic Shores hub still has two sr-only nodes on it today. Both are what the class is for: accessible names for controls that show only an icon. When the codebase uses a class for one purpose, grep for it becomes a review tool instead of a list of things to read carefully.
See it: open cogniprep.app/games/arctic-shores and scroll to "About CogniPrep Arctic Shores Practice". That heading is rendered text now, not an offset div. In DevTools, run document.querySelectorAll('.sr-only').length in the console: it returns 2, and both are button labels. Then compare the "14 games" sentence against the count of cards further up the page. That is the check hidden copy never had to pass.
The same block is on all 24 provider hubs, so cogniprep.app/games/shl and cogniprep.app/games/pymetrics will show you the same section with their own numbers in it.
Top comments (0)