Shareable, Accessible, and Non-Blocking: Three Upgrades for a Client-Side Text Repeater
DEV Community

Shareable, Accessible, and Non-Blocking: Three Upgrades for a Client-Side Text Repeater

Most "simple tool" tutorials stop the moment the output appears on screen. But once real people start using a tool, three questions show up fast. Can I send my exact settings to a friend without retyping everything? Does this work with a screen reader, or does it just announce noise? And what happens to the UI when I push the count to ten thousand?

I ran into all three while maintaining a text repeater called happy birthday 100 times, and none of the fixes needed a framework. Here is how I handled each one in plain JavaScript.

1. Make the state shareable with the URL

People rarely use a repeater alone. They configure it ("Happy Birthday", 100 times, new line separator") and want to send that exact setup to someone else. The cheapest solution is to keep state in the query string.

const clamp = (n, min, max) => Math.min(Math.max(n, min), max);

function readState() {
  const params = new URLSearchParams(location.search);
  return {
    phrase: params.get('p') ?? 'Happy Birthday',
    count: clamp(parseInt(params.get('n'), 10) || 100, 1, 10000),
    sep: params.get('s') ?? 'newline',
  };
}

function writeState({ phrase, count, sep }) {
  const q = new URLSearchParams({ p: phrase, n: String(count), s: sep });
  history.replaceState(null, '', `?${q}`);
}

Two details matter here:

  • Clamp everything you read from the URL. A link with ?n=99999999 should not be able to freeze someone's browser.
  • Never inject URL values with innerHTML. Put them into inputs via .value or into the page via textContent. A shared link is untrusted input, even when a friend sent it.

replaceState (instead of pushState) keeps the back button from filling up with one history entry per keystroke.

2. Don't make screen readers read 10,000 lines

This one surprised me. My first instinct was to put the output in a live region so assistive tech would "announce the result." That is a mistake for large outputs, because a polite live region will try to read the whole thing. The better pattern is to separate the content from the announcement:

<label for="output">Generated text</label>
<textarea id="output" readonly rows="10"></textarea>
<p id="status" role="status" aria-live="polite"></p>
const output = document.getElementById('output');
const status = document.getElementById('status');

function showResult(text, count) {
  output.value = text;
  status.textContent = `Generated ${count} lines. Use the copy button or select the text area to read it.`;
}

The textarea is a normal, labelled, focusable control. A user can move into it and navigate it with their usual reading commands. The status region only announces one short sentence. Small thing, big difference for someone using a screen reader.

A few more quick wins:

  • Give the copy and download buttons real text labels, not icon-only buttons.
  • After copying, update the status region ("Copied to clipboard") instead of only changing a visual tooltip.
  • Respect prefers-reduced-motion if you animate the "copied" feedback.

3. Move heavy work off the main thread

Here is an honest note first. For plain repetition, String.prototype.repeat is so fast that a worker is overkill:

const text = (phrase + sep).repeat(count - 1) + phrase;

But the moment you add real per-line work (numbering, grapheme-aware truncation, custom templates), the main thread starts to feel it. That is when a Web Worker earns its place. You can even create one inline, with no extra file:

const workerSource = `
  self.onmessage = ({ data }) => {
    const { phrase, count, sep, numbered } = data;
    const lines = new Array(count);
    for (let i = 0; i < count; i++) {
      lines[i] = numbered ? (i + 1) + '. ' + phrase : phrase;
    }
    self.postMessage(lines.join(sep));
  };
`;

const worker = new Worker(URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' })));

function generate(options) {
  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data);
    worker.postMessage(options);
  });
}

Then the click handler stays responsive:

button.addEventListener('click', async () => {
  button.disabled = true;
  status.textContent = 'Generating...';
  const text = await generate(readState());
  showResult(text, readState().count);
  button.disabled = false;
});

Caveats worth knowing:

  • A strict Content Security Policy can block blob: workers. If you control the policy, allow worker-src blob:, or ship the worker as a separate file instead.
  • Posting a very large string between threads copies it. For a text tool that is fine, but measure before assuming a worker is always faster.
  • Always keep a fallback. If Worker fails to construct, run the same logic on the main thread.

Putting it together

The flow is simple:

  • On load, call readState() and fill the form.
  • On submit, call generate() and showResult().
  • After generating, call writeState() so the URL always matches what the user sees.

That is a shareable, screen-reader-friendly, non-blocking tool in well under a hundred lines, with no npm install anywhere.

Takeaways

  • URL state is the cheapest sharing feature you can build. Clamp it and treat it as untrusted.
  • Announce results, don't dump them. Keep big content in a labelled control and use a short role="status" message.
  • Use a worker when per-item work grows, not by default. Measure first.
  • Small utilities deserve real engineering. The users hitting them on a cheap phone, with a screen reader, or from a shared link are still users.

If you've added accessibility or sharing features to a small client-side tool, I'd love to hear what worked for you in the comments.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.