Accessible Copypasta
I was following along with Anh’s December Adventure where she mentioned making changes to her copypasta page:
“Anyway, like my lorem ipsum page, this is an entry in my ‘specialized tools so I don’t have to google annoying shit and fight ads and bad web design and clickbait etc.’ arsenal (population: 2).”
It’s a page with a bunch of buttons that copy hard to type/remember symbols and emojis to your clipboard for future pasting. I thought this was a brilliant idea, because Anh’s annoying shit situation above happens to me like, at least twice a week. The amount of times I’ve typed “melting face emoji” into google, my god.
I could’ve just used Anh’s page, but I figured it might be useful to have my own set of copyable symbols, so I went ahead and coded up my own /copypasta page:
While this page might seem like it should have been simple to make, I ran into a number of tricky accessibility and UX problems.
In the rest of the article, I’ll go over those challenges, and explain how and why we as web developers can fix them.
We’ll talk about screen readers, diversity through the lens of emoji, vestibular disorders, and the apathy that the tech industry at large has towards their users.
Give my /copypasta page a quick look, then come back to this blog post and strap in!
Emoticons and Screen Readers
Emoticons (or Kaomoji) are drawings made with computer text. They were commonly used on the early internet and in phone texting before image based emojis were available.
They range from the simple one or two character ones that inspired modern emojis:
:D
to complicated drawings that are a whole mood:
(╯°□°)╯︵ ┻━┻
Most of the time, sighted people are able to figure out what the author is trying to convey. (Though it is worth noting that emoticon meanings can vary across cultures!)
Screen readers, however, have a much trickier time with emoticons. “:D” isn’t a word in any existing language, so they default to reading it out character by character: colon, D.
Here’s what the table flip emoticon above sounds like on through the Voice Over program on my Macbook:
Horrible!
Fortunately, we can use aria-labels and the role
attribute to make this better, like so:
<span role="img" aria-label="An emoticon depicting a person angrily flipping a table, made using many non-ascii symbols.">
(╯°□°)╯︵ ┻━┻
</span>The role="img" makes screen readers treat the element
like an image instead of like text. Then the aria-label
attribute holds the image description that will be read out by the
screen reader.
Here’s a recording where we’ve applied the fix:
Much better :)
Skin Tone Modifiers And My Descent Into Unicode Hell
Some, but not all, emojis can be modified to display with different skin tones.
For example, the thumbs-up emoji can be shown in these ways:
This is useful because it allows people to choose something that better represents them, and it helps defeat the narrative that only white people use the internet – and also helps highlight how non-inclusive some online spaces actually are.
We’re not all Simpsons characters:
On my copypasta page, I have a dropdown selector to allow you to change which skin tone is applied to the copyable emojis.
You can see a reproduction of it here:
- 👍
- 👎
- 🙌
- 💪
- 🤦♀️
- 🧔♂️
These emoji modifications are done by representing each emoji with two symbols. The first is the “base” emoji, e.g. the yellow thumbs up, the second is a modifier character representing the skin tone, specifically one of these symbols:
🏿 🏾 🏽 🏼 🏻
These boxes are a compressed representation of the Fitzpatrick scale a “numerical classification for human skin color”. And in fact, if you use the Mac VoiceOver Screen Reader, that’s what it will read out for these boxes in the select dropdown, e.g. “Emoji Modifier Fitzpatrick Type 6”.
So a 👍🏾 is represented as a 👍 with a 🏾 next to it.
The way these symbols are represented in computer memory is insanely complicated (Unicode, and UTF-8 and UTF-16 and codepoints and Zero Width Joiners 😱😱😱).
As a result most software that deals with it is buggy and inconsistent. It’s taken me ten minutes to write the above two paragraphs, because my code editor keeps messing up when trying to copy-paste the fitzpatrick squares, and then I couldn’t get my browser to render the square when it was next to non-emoji text.
I had to change the font-family to emoji
for that one character to get it to work on my laptop – and it wouldn’t
at all surprise me if it was still broken on some phone browsers.
For the copypasta page, I initially had a CSS-only solution (following this example from CSS Tricks), but it didn’t work on iOS Safari, so I then had to spend half an hour re-learning how Unicode code points work to make a Javascript solution, and changing my HTML to use HTML character entities and blaaaaaaaah. This shit is way too complicated.
Click for commented and explained code. Not for the faint of heart.
<select id="skin-tone-selector">
<option value="">🟨</option>
<!--
The values are "HTML Character Entities" which allow you to write out the hex
form of Unicode Code Points.
If I used the actual symbols here, they'd get wrapped or something and I had
a hard time getting at the hex in the selector value field in javascript.
This is so complicated and brittle!!!
-->
<option value="🏿">🏿</option>
<option value="🏾">🏾</option>
<option value="🏽">🏽</option>
<option value="🏼">🏼</option>
<option value="🏻">🏻</option>
</select>
<ul class="emoji-list">
<!-- don't have to use html entities here though -->
<li>👍</li>
<li>👎</li>
<li>🙌</li>
<li>💪</li>
<li>🤦♀️</li>
<li>🧔♂️</li>
</ul>const skinToneSelector = document.getElementById('skin-tone-selector');
const emojiEls = Array.from(document.querySelectorAll('.emoji-list li'));
skinToneSelector.addEventListener("change", (e) => {
emojiEls.forEach(el => {
// get just the first code point, representing the base emoji (e.g. 👍)
const baseEmoji = String.fromCodePoint(el.innerText.codePointAt(0));
// add the first code point with the modifier code point to produce
// the appropriate emoji
el.innerText = baseEmoji + skinToneSelector.value;
});
});This code was copied directly from the selector earlier in this page, check it out in the dev tools.
I’ve included this section to talk about how difficult it is to get this right, even on a really simple webpage.
I’ll talk more about the implications of this in the conclusion of this article, but for now I’ll just say that it’s important to dive into this unicode hell, even though it’s hard.
Is it pronouned “GIF” or “Pain In The Ass”?
My partner is one of a surpisingly large group of people with some sort of “vestibular disorder”. I’ve seen estimates that say somewhere around 35% of Americans have some form of this, at some point in their lives.
In her case, she suffered from an unexplained week-long attack of vertigo in her mid twenties, which has left her with long-term motion-sickness and a strong aversion to moving/blinking lights.
For a while, she had to close her eyes whenever there was a pan shot on TV, and me scrolling on my phone next to her would give her nausea.
It has improved some after years of vestibular physical therapy, but digital screens with lots of motion are still unpleasant for her.
Fortunately the internet is a really respectful and calm place.
Don’t even get me started on airports.
In the last few years there’s been a small push to improve this on the web browsing side, with operating systems giving you a “prefer reduced motion” mode. On your phone or computer you can usually find it somewhere in the accessibility settings.
These settings then get passed to the browser, where programmers can access the value through CSS and Javascript, writing code to turn off animations or videos if people have the setting enabled.
Which finally brings us back to my copypasta page which has an Emotes section that, without that setting, would be very unpleasant for my partner:
I managed to get the gifs to respect the
prefers-reduced-motion setting, but it took some doing.
Pausing a Gif
Gif is the meme format of the web and I love it. Did you know that the first color images on the web was a gif.
They were the only image format on the web that supported animations, and pretty much everything with a screen knows how to display them. Gifs are great.
Also: Gifs suck. They use an ineffective lossless compression algorithm, meaning that a gif is often larger than the equivalent mp4 file.
They only support 256 colors, which mean that videos converted into gifs have potato quality.
And crucially for our vestibular conditions – they can’t be paused.
Most gif emotes are set to loop infinitely. This is practical when loaded on a page below the scroll fold; if they only played once they would play when the page is first loaded and be stopped by the time the reader sees them.
But it means that on a page with lots of gifs its this infinitely looping cacophany.
There’s also no programmatic API to pause these gifs – they’re either
loaded and running infinitely or they’re blank. Which means there’s no
easy way for web developers to respect the
prefers-reduced-motion query. Theoretically, browsers could
do this for us, maybe using a technique similar to what I’m doing below,
but they sadly don’t.
So what do we do?
After some research, I came across solutions like ctrl-freaks’ freezeframe.js,
a Javascript library that automatically “pauses” gifs by replacing them
with a <canvas> element with the first frame of the
gif drawn on top.
When you hit play, it swaps the canvas out for the original gif. This solution isn’t perfect because if you pause a gif mid play-through you can only restart it again from the beginning, but I figured it’d work well enough for my simple copypasta page.
Rather than using an existing library (which tended to have many more
features than I wanted, and often didn’t automatically listen to the
prefers-reduced-motion setting), I wrote my own pared down
version.
Click 4 commented code
function isGif(el) {
// regex is bad, but meh
return /^(?!data:).*\.gif/i.test(el.src);
}
let gifs;
async function createGifCanvases() {
return await Promise.all(gifs.map(img => {
if (img.complete) {
return Promise.resolve(createCanvas(img));
}
// wait for image to load before creating canvas, since we need
// the first frame of the gif
let resolver;
const promise = new Promise((r) => {
resolver = r;
});
img.addEventListener('load', () => {
resolver(createCanvas(img));
}, { once: true });
return promise;
}));
}
function createCanvas(img) {
const width = img.width;
const height = img.height;
const canvas = document.createElement('canvas');
canvas.classList.add('gif-canvas');
canvas.width = width;
canvas.height = height;
canvas.style.display = "none";
// this line is the magic. When you pass a gif to drawImage, the first frame
// is automatically picked out and drawn to the canvas.
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
// now that I see this, I realize that alt text is probably broken, since we'r
for (i = 0; i < img.attributes.length; i++) {
attr = img.attributes[i];
if (attr.name !== '"') { // test for invalid attributes
canvas.setAttribute(attr.name, attr.value);
}
}
// canvases don't support the alt attribute. This copies the image's alt text to an aria-label.
canvas.setAttribute("aria-label", canvas.getAttribute("alt"));
canvas.removeAttribute("alt");
// fun fact, I only caught the above when writing this blog post, previously I wasn't doing this and
// the canvases were broken with Screen Readers. Making screen-reader accessible websites is hard, especially
// if you don't use a screen reader yourself!
img.parentNode.insertBefore(canvas, img);
return canvas;
}
function toggleGifAnimation() {
// this lovely tri-state logic essentially checks which setting (the on the page button or the OS setting) has changed
// most recently, and goes with that.
const prefersReducedMotion = inputShouldOverride ? stopAnimationsInput.checked : window.matchMedia('(prefers-reduced-motion: reduce)').matches;
gifs.forEach(gif => {
gif.style.display = prefersReducedMotion ? "none" : "block";
});
canvases.forEach(canvas => {
canvas.style.display = prefersReducedMotion ? "block" : "none";
});
stopAnimationsInput.checked = prefersReducedMotion;
}
async function main() {
// could've just hard-coded which gifs since it's for a static page,
// but I can re-use this generic solution somewhere else maybe
gifs = Array.from(document.images).filter(isGif);
const canvases = await createGifCanvases();
const stopAnimationsInput = document.querySelector("#stop-animations");
let inputShouldOverride = false;
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
stopAnimationsInput.checked = motionQuery.matches;
stopAnimationsInput.addEventListener('change', () => {
inputShouldOverride = true;
toggleGifAnimation();
});
motionQuery.addEventListener('change', () => {
inputShouldOverride = false;
toggleGifAnimation();
});
toggleGifAnimation();
}
window.addEventListener('load', () => {
await main();
});One especially tricky bit is that I have both a checkbox on the page
that toggles the animations as well as the
prefers-reduced-motion setting from the operating system.
There’s an argument to be made that the on-page button is superfluous,
and we should only use the OS state, but I find that most people don’t
know that these accessibility settings exist, so I wanted the
button.
Of course, having two boolean flags to control the same behavior results in a tri-state:
- both flags are false -> false
- both flags are true -> true
- one flag is true, one if false -> what should we do?????
I chose to make it that we always start with whatever the OS setting
is, and then respect whichever flag changed most recently. If you have
prefers-reduced-motion on but then manually uncheck the
box, the animations will play. Tricky!
Finally, after all this fuss, we’ve done the browser’s job for it and turn off gifs when users want us too. Bleh.

In conclusion
My intent with this article was to demonstrate just how difficult it is to build a fully accessible website in the year of our lord 2026.
Computers were designed by mostly white, mostly male, mostly able, mostly American engineers to display a subset of English text. If you exist outside those assumptions, you’re dealing with a whole lot of buggy software, bad APIs, missing functionality, and extra UX surface area (as I’m sure you don’t need me to tell you).
For this simple joke copypasta page, I spent hours and hours diving into screen readers, the depths of unicode, image formats and more. It was frustrating and a lot of work.
But it’s important to take on that frustration when coding, especially as a white male able American like myself. I have the luxury of not having to deal using inaccessible software most of the time, because our technology was designed by people like me, with people like me in mind.
And so, each bit of frustration I take on in the development process is an opportunity to reduce that frustration for the people using my software.
And one day, I’ll need some of these accommodations too. My eyes have never been great (I’ve worn glasses since I was five), and they’re getting worse over time. It’s probably pretty likely that I’ll use a screen reader (or at least dramatic magnification) to interface with software at some point in my life. I already have some sort of repetitive stress injury in my wrists – odds are high that I’ll have flare-ups or general arthritis that prevent me from using a mouse or keyboard.
Or maybe I’ll get vertigo out of the blue like my partner did. Who knows!
Life changes, we’re all aging, and we all need or will need accommodations.
Hell, who needs “accomodations” says more about the assumptions of the people designing the software than it does about the people who need accomodations.
But the tech industry doesn’t care about accessibility. Every product I worked on at Google was inaccessible in a myriad of ways. Accessibility (and security) was always an afterthought, the extra 10% to tack on to a project once the “important” stuff was finished – and both were always the first corners to be cut when deadlines approached.
Ideally web browsers and operating systems would do a lot more for their users, but despite the best efforts of the W3C accessiblity folks (shout-out to them, they’ve made some large improvements in the last 15 years), tech isn’t going to get better until the people making it actually give a damn.
And most people who make software are like me – currently insulated from the worst of it.
In-so-far as LLMs work at all, they’re only going to make things worse – they’re trained on code and patterns that aren’t accessible, and so they just regurgitate buggy and inaccessible software at an ever increasing rate.
The only way we’re going to improve this is if we, techy people, really dive in and try to make it better.
We need to make our spaces more inclusive, so that the people on the margins (those who tend to need the most accomodations) get the opportunities to to build their own software that works for them.
We need more diversity, we need more empathy, we need each other.
I hope to see you out there, making the world a comfier place for me, and I’ll do my best to make it comfier for you.
Thanks for reading.
Acknowledgments
Thanks again to Anh for the copypasta inspiration!
I am grateful to and inspired by the disability justice movement.
For anybody looking to learn more, I recommend Disability Intimacy: Essays on Love, Care, and Desire, edited by Alice Wong.
Shout out to my wonderful partner for the editing help and the generative discussions. Love you. ❤️