I run CopyCute, a small site of cute symbols and kaomoji. I started it after watching a friend search all over the place just to find a few emoji they wanted. Almost everything on the site is plain text, which is the whole point: you tap an item, it goes to your clipboard, and you paste it into a bio or a nickname.

Plain text has one catch. I don't control how a character is drawn. The visitor's fonts do. For most symbols that's fine, but emoji have a hard cutoff on Windows 10, and I wanted to handle it without making the site worse for everyone else.

The problem

Windows 10's last emoji update was the May 2019 Update (version 1903), which added Emoji 12.0. Anything newer, like 🩷 pink heart (Emoji 15) or 🫧 bubbles (Emoji 14), only shipped with Windows 11. Windows also has no flag emoji at all; it shows two letters instead.

My development PC runs Windows 10, so I hit this myself while building the site: some emoji showed up as empty boxes. On my site that affects 10 different emoji out of 2,862 items. Not many, but they're in popular sections like hearts and flowers.

I didn't want to just delete them. They look fine on phones and on Windows 11, so I wanted to see how far I could get with compatibility instead. I also didn't want to replace them with images for everyone, because then most visitors would see pictures that don't match their own emoji.

So the plan was: find the risky emoji at build time, test them on the visitor's device, and only swap in an image where the test fails.

Step 1: list every emoji Windows 10 can't draw

Unicode publishes emoji-test.txt, which lists every emoji with the version it was added in. A small script keeps the fully-qualified sequences newer than Emoji 12.0, plus every flag:

const m = line.match(/^([0-9A-F ]+?)\s*;\s*fully-qualified\s*#\s*\S+\s+E(\d+\.\d+)\s+(.*)$/); if (!m) continue; const v = Number(m[2]), name = m[3].trim(); const flag = subgroup === 'country-flag' || subgroup === 'subdivision-flag'; if (!flag && v <= 12.0) continue; // the Emoji 12.1 skin tones of "people holding hands" did reach Windows 10 if (!flag && v === 12.1 && /^people holding hands/.test(name)) continue; 

With Emoji 18.0 data that gives 1,204 sequences, 262 of them flags. The script writes them to a JS file keyed by code points, with FE0F (the emoji presentation selector) removed so matching doesn't depend on it.

At build time, every item on every page is scanned for those sequences, longest match first, and each match is wrapped:

<span class="emo" data-emo="1fa77">🩷</span> 

Step 2: test on the device, not the user agent

Checking the user agent doesn't work: Windows 11 still reports "Windows NT 10.0". Chromium can tell them apart with navigator.userAgentData.getHighEntropyValues(['platformVersion']), but Firefox can't, and the font installed matters more than the OS version anyway.

So the page draws each risky emoji on a small canvas and looks at the pixels. The trick is to draw it twice, in two different fill colours. A colour emoji glyph ignores fillStyle. An empty box, or a black-and-white fallback glyph, is painted in it.

const draw = (colour) => { ctx.clearRect(0, 0, SIZE * 2, SIZE * 2); ctx.fillStyle = colour; ctx.fillText(ch, 0, 4); return ctx.getImageData(0, 0, SIZE * 2, SIZE * 2).data; }; const a = draw('#f00'), b = draw('#00f'); let ink = false; for (let i = 0; i < a.length; i += 4) { if (a[i + 3] > 0) ink = true; if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) return false; // took the fill colour } if (!ink) return false; // nothing drawn at all 

Same pixels in both colours, and some ink, means a real colour emoji.

ZWJ sequences need one more check. A font that doesn't know a sequence like ❤️‍🔥 draws its parts side by side, and each part passes the colour test. So if the sequence is much wider than its first emoji, I treat it as unsupported:

if (ch.includes('\u200d')) { const first = String.fromCodePoint(ch.codePointAt(0)); if (ctx.measureText(ch).width > ctx.measureText(first).width * 1.5) return false; } 

The test waits for document.fonts.ready and runs in requestIdleCallback, so it doesn't block the first paint. Results are cached in localStorage, keyed by the user agent, so it only runs once per browser version.

Step 3: swap the picture, keep the text

When the test fails, the emoji is replaced on screen by a PNG from Google's open-source Noto Emoji set (Apache 2.0). The original character stays in the DOM, visually hidden, so selecting and copying the line still gives the real emoji. The copy buttons read from a data-copy attribute anyway, so what you paste is never an image.

The section also shows a short note: some emoji here are too new for this device, so they're shown as pictures, and the copied text is still the real emoji.

One trade-off: on a first visit, the box can show for a moment before the test runs. After that the result is cached.

Step 4: make the build fail when I forget

The hardest part wasn't the code. It was checking the result on Windows 10, Windows 11, Android and iOS. So I made the build check what it can by itself. After astro build, a script reads the generated HTML and fails if:

  • a Windows 10 emoji appears outside a wrapped item, so it would have no fallback
  • a .emo wrapper is around an emoji Windows 10 can draw
  • a section has wrapped emoji but no note
  • a fallback PNG or the Noto licence file is missing

A separate Python script uses fontTools to check every non-ASCII character on the site (1,767 right now) against 8 common fonts. The latest run found none without a glyph. That doesn't prove every phone draws them, so the site says so instead of promising they work everywhere.

What I'd still like to improve

  • The box can flash briefly on the first visit. I could hide wrapped emoji until the test finishes, but that hides them for most people who don't need it.
  • The colour test is a heuristic. If you know a case where a colour glyph changes with fillStyle, or a box glyph doesn't, I'd like to hear it.
  • I want to keep adding emoji and symbols while keeping each page clean, so people can still find what they need quickly.

The list of 1,204 sequences is in a small open dataset: cute-symbols-data.

So what's missing? If there's an emoji or symbol you use all the time and can't find on the site, tell me in the comments.