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=99999999should not be able to freeze someone's browser. -
Never inject URL values with
innerHTML. Put them into inputs via.valueor into the page viatextContent. 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-motionif 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, allowworker-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
Workerfails 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()andshowResult(). - 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.
Top comments (0)