DEV Community

Joe Lin for BeGoodTool.com

Posted on

Kaomoji aren't a special format — they're just Unicode strings (and that changes how a copy-paste tool works)

A while back I noticed that most “special character” pages are really just giant dumps of glyphs. That works until you mix , , 😀, and ¯\\_(ツ)_/¯ in the same interface. One of those is a single symbol, one is an emoji, and one is basically a tiny string drawing made out of regular characters.

When I built a copy-paste collection for them, the interesting part wasn't the list itself. It was figuring out how to make one-character symbols and full kaomoji behave like the same kind of data without turning the UI into a mess.

The useful abstraction is just { char, kw }

The core data structure is intentionally boring. Every item becomes an object with the visible text and a keyword slug:

const toSymbols = (list) => list.map(([char, kw]) => ({ char, kw }));

const rawKaomoji = [
  ["(´・ω・`)", "happy"],
  ["(╯°□°)╯︵ ┻━┻", "table-flip"],
  ["¯\\_(ツ)_/¯", "shrug"],
];

const categories = [
  { id: "arrows", symbols: toSymbols(rawArrows) },
  { id: "kaomoji", symbols: toSymbols(rawKaomoji) },
  { id: "emoji", symbols: toSymbols(rawEmoji) },
];
Enter fullscreen mode Exit fullscreen mode

That sounds trivial, but it's the reason the page can treat a right-arrow and a table-flip face with the same button component. Kaomoji aren't a special rich-text format here. They're just strings made from ordinary Unicode characters, stored the same way as everything else.

There's even a comment in the component noting that the symbol content itself doesn't need per-item translation — only the category labels go through i18n. That's a good fit for this kind of dataset: the glyphs are the product, and the UI chrome is what's language-specific.

Search stops caring about categories the moment you type

The page has category tabs, but search switches the mental model from “browse one bucket” to “scan everything”:

const visibleSymbols = computed(() => {
  if (isSearching.value) {
    const kw = searchKeyword.value.trim().toLowerCase();
    const result = [];
    categories.forEach((cat) => {
      cat.symbols.forEach((item) => {
        if (item.kw.indexOf(kw) > -1 || item.char === searchKeyword.value.trim()) {
          result.push(item);
        }
      });
    });
    return result;
  }
  return activeCategoryObj.value.symbols;
});
Enter fullscreen mode Exit fullscreen mode

I like this because it's deliberately simple. No extra search index, no special handling per category, no separate kaomoji parser. Every category participates in the same linear scan, and exact-character matching means you can paste a symbol into the box and find that exact entry back.

That simplicity also explains the keyword design. The internal search terms are slugs like arrow-right, heart-outline, and table-flip, so the component can stay small and predictable.

Kaomoji force you to think about layout, not just data

A symbol picker looks easy right up until you remember that some “symbols” are ten-plus characters long. The component handles that with a tiny font-size heuristic:

function symbolFontSize(char) {
  const len = (char || "").length;
  if (len <= 2) return "26px";
  if (len <= 4) return "20px";
  if (len <= 8) return "15px";
  return "12px";
}
Enter fullscreen mode Exit fullscreen mode

Then each button applies it directly:

<button
  class="symbolBtn"
  :style="{ fontSize: symbolFontSize(item.char) }"
  @click="copySymbol(item.char)"
>
  {{ item.char }}
</button>
Enter fullscreen mode Exit fullscreen mode

That keeps arrows and hearts large, while shrinking long kaomoji enough to fit the same grid. The CSS backs it up with word-break: break-word, white-space: normal, and a centered grid cell, which is exactly the kind of boring UI detail that makes this sort of tool feel usable instead of chaotic.

Copying text reliably still needs a fallback path

The copy logic doesn't assume the modern Clipboard API will always work:

function copySymbol(char) {
  if (!char) return;

  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(char).catch(() => fallbackCopy(char));
  } else {
    fallbackCopy(char);
  }

  message.success({ content: t("specialCharEmojiCopy.copiedToast") });
  addToRecent(char);
}
Enter fullscreen mode Exit fullscreen mode

And the fallback is the old hidden-textarea + document.execCommand("copy") pattern. That isn't glamorous, but it matters for compatibility.

The same section also keeps a recent-history list in localStorage, capped at 20 items and deduplicated by moving the latest copied symbol to the front. It even wraps storage reads/writes in try/catch, which is worth doing because browsers can block localStorage or hand you corrupted data.

Where this still gets weird

A few honest gotchas come with this territory:

  • Clipboard writes can still fail depending on browser permissions, context, or legacy API support. The fallback helps, but execCommand("copy") is still legacy behavior.
  • The sizing heuristic uses JavaScript string .length, which counts UTF-16 code units, not visual graphemes. For complex emoji sequences, the “length” the code sees is not always the number a human would expect.
  • Search is intentionally lightweight: it matches internal keyword slugs or an exact symbol string. That's simple and fast, but it's not the same thing as full multilingual semantic search.
  • Rendering is platform-dependent. These are text characters, not images, so the same emoji or symbol can look surprisingly different across fonts and operating systems.

I turned that into a small free tool here: Special Characters & Kaomoji Copy Paste. It ended up being less about “emoji” and more about treating lots of different Unicode text shapes as one copyable interface.


Available in other languages

Top comments (0)