ehsan.blog
~/blog/how-to-make-one-at-a-time-react-ui-crawlable — zsh
cat how-to-make-one-at-a-time-react-ui-crawlable.md

How to make tabbed/one-at-a-time React UI crawlable with sr-only mirrors

·4 min read

My portfolio’s experience section is a set of tabs — click a company on the left, its details show on the right. It looks great, but it has a hidden cost: only the active company is ever in the DOM. Every other job — its role, dates, tech stack, and achievements — simply isn’t in the HTML. That’s invisible to search crawlers and awkward for screen readers. Here’s the small, robust fix I used: an sr-only mirror of the full dataset.

The problem: “one at a time” means “one in the DOM”

Any UI that shows one item at a time — tabs, accordions, carousels, a “read more” toggle — usually renders only the visible item. In my experience section the main panel renders a single job derived from the active index:

tsx
// src/components/portfolio/ExperienceSection.tsx
const [active, setActive] = useState(0)
const job = experience[active]   // just ONE job object
// ...renders job.company, job.role, job.highlights, etc.

So if I have five companies, the rendered HTML contains details for exactly one of them. The other four exist only in my experience data array in JavaScript — they are never emitted as HTML.

Why that matters:

  • Search crawlers read the HTML. Content that’s only reachable after clicking a tab often isn’t indexed. Four-fifths of my work history would be missing from what Google sees.
  • Screen readers navigate the DOM. A user tabbing through can only encounter what’s rendered; the hidden jobs don’t exist for them either.

This is especially painful on a prerendered (SSG) site, where the whole point is that the static HTML contains real content — but a tab UI defeats that by design.

The fix: mirror the full dataset in a visually-hidden block

The trick is to render the entire dataset a second time, in a block that is present in the DOM but not shown on screen. The sr-only utility class (from Tailwind, and a long-standing accessibility convention) hides an element visually while keeping it available to assistive tech and crawlers — it clips the element to a 1px box off-screen instead of using display: none (which would remove it from the accessibility tree too).

Here’s the actual mirror from my experience section:

tsx
// src/components/portfolio/ExperienceSection.tsx
<h2 id="experience-heading" className="sr-only">
  Work Experience
</h2>

{/*
  The visible UI shows one company at a time via a tab/listbox. This
  visually-hidden block exposes the full work history to screen readers
  and search engine crawlers so no experience detail is lost.
*/}
<div className="sr-only">
  {experience.map((exp) => (
    <article key={`sr-${exp.company}`}>
      <h3>
        {exp.role} at {exp.company}
        {exp.url ? ` (${exp.url})` : ""}
      </h3>
      <p>{exp.period}</p>
      <p>Tech stack: {exp.stack.join(", ")}</p>
      <ul>
        {exp.highlights.map((point, i) => (
          <li key={i}>{point}</li>
        ))}
      </ul>
    </article>
  ))}
</div>

The key points:

  • It maps over the whole experience array, not experience[active]. Every job ends up in the HTML.
  • It uses semantic, plain markup — article, h3, p, ul/li. No styling, no interactivity; it exists purely to carry information. Crawlers and screen readers love simple semantic HTML.
  • It sits right next to the visual UI, so it’s part of the same prerendered output.

Now the fancy tab interface stays exactly as designed for sighted mouse users, while the sr-only block guarantees the full content is always in the DOM.

flowchart TD
  A[experience data array] --> B[Visible tabs: only active job in DOM]
  A --> C[sr-only mirror: ALL jobs in DOM]
  B --> D[Nice UX for sighted users]
  C --> E[Crawlers and screen readers see everything]

Keep the mirror in sync

The one maintenance rule: the mirror and the visible UI must both read from the same source of truth. In my code that’s the experience array — both the tabs and the sr-only block iterate the identical data, so they can’t drift apart. If I added a company, both update automatically. Never hand-copy content into the mirror; always render it from the same array/object the visible UI uses.

A couple of quick sanity checks after building:

  • View the page source (or the prerendered dist/index.html) and confirm every item’s text is present.
  • Tab through with a screen reader or run an accessibility audit to confirm the hidden content is announced.

The same sr-only utility shows up in the site’s broader accessibility work — see adding an accessible skip link and ARIA-correct navigation. If your one-at-a-time UI also involves an entrance animation like a typewriter, the SSG-safe typing animation pattern keeps that text in the prerendered HTML too.

What to remember

  • One-at-a-time UIs (tabs, carousels, accordions) put only the active item in the DOM.
  • That hides the rest of your content from crawlers and screen readers.
  • Fix it with an sr-only block that renders the entire dataset in plain semantic HTML.
  • sr-only hides visually but stays in the accessibility tree and the crawlable HTML — unlike display: none.
  • Drive both the visible UI and the mirror from the same data source so they never fall out of sync.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.