DEV Community

CJ Z
CJ Z

Posted on

Designing a Copy-Friendly Unicode Text UI

Unicode text faces look simple. They are just strings, after all.

But a good copy-and-paste interface for them has to solve several small problems at once: characters have different widths, some fonts render them differently, the clipboard API is not guaranteed to work everywhere, and a button that says "copy" is not very useful if the user cannot tell whether the action succeeded.

I ran into these details while building a searchable collection of Japanese-style text faces. The same lessons apply to any interface where the primary action is copying a piece of text: code snippets, shell commands, color values, usernames, or generated prompts.

Start with data, not markup

It is tempting to put a list of strings directly inside a component. That works for a prototype, but it quickly makes search, categories, metadata, and static pages harder to maintain.

A small typed model is enough to keep the content separate from the UI:

type TextFace = {
  value: string
  category: string
  description?: string
}

const faces: TextFace[] = [
  { value: "(。•ㅅ•。)", category: "cute" },
  { value: "(╥﹏╥)", category: "sad" },
  { value: "(=^・ω・^=)", category: "animals" },
]
Enter fullscreen mode Exit fullscreen mode

This gives the rest of the application one source of truth. The home page can render a mixed list, a category page can filter by category, and a search function can operate on value without knowing anything about the layout.

The model also makes it easier to add editorial context. Some text faces are friendly, some are dramatic, and some can be misread outside a casual conversation. A short description can be more useful than another dozen nearly identical entries.

Treat the string as the source of truth

The visual representation and the copied value should never be separate pieces of data.

function CopyableFace({ face }: { face: TextFace }) {
  async function handleCopy() {
    await navigator.clipboard.writeText(face.value)
  }

  return (
    <button type="button" onClick={handleCopy}>
      <span aria-hidden="true">{face.value}</span>
      <span className="sr-only">Copy {face.value}</span>
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Keeping one canonical value avoids a common bug: the string shown on screen contains a space or a variation selector that the copied string does not, or vice versa.

It is also worth deciding how to handle normalization. Unicode normalization can be helpful when comparing search input, but normalizing the value before copying may change the exact sequence that the author intended. In practice, I normalize the search query and preserve the original string for display and clipboard operations.

function matches(face: TextFace, query: string) {
  const normalizedQuery = query.trim().normalize("NFC").toLocaleLowerCase()
  const normalizedValue = face.value.normalize("NFC").toLocaleLowerCase()

  return normalizedValue.includes(normalizedQuery)
}
Enter fullscreen mode Exit fullscreen mode

For an application that handles combining marks or emoji variation selectors, test the real content rather than assuming that JavaScript string length matches what a user sees. value.length counts UTF-16 code units, not grapheme clusters or visual columns.

Make clipboard behavior observable

Calling navigator.clipboard.writeText() is only half of the interaction. Users need feedback, and the call can fail because of permissions, an insecure context, browser policy, or a non-browser environment.

async function copyText(value: string) {
  if (!navigator.clipboard) {
    throw new Error("Clipboard API is unavailable")
  }

  await navigator.clipboard.writeText(value)
}
Enter fullscreen mode Exit fullscreen mode

The component should expose three states:

  1. Ready to copy
  2. Copied successfully
  3. Copy failed

The success state does not need to be elaborate. Changing the accessible label to "Copied" for a short period and showing a small status message is usually enough. The failure state should offer a fallback, such as selecting the text or displaying it in a field that the user can copy manually.

Do not rely on color alone. A check icon, text label, or live region makes the result understandable to people using screen readers or high-contrast themes.

<span role="status" aria-live="polite">
  {status === "copied" ? "Copied" : null}
</span>
Enter fullscreen mode Exit fullscreen mode

Also remember that clipboard access is an interaction feature, not a rendering feature. The page should remain useful if copying is unavailable.

Design for variable-width text

Text faces are not a normal grid of identical labels. Their widths vary, and the same characters may occupy different visual widths depending on the operating system and font.

Some practical CSS rules help keep the layout stable:

.face-card {
  min-width: 0;
  min-height: 4rem;
  display: grid;
  place-items: center;
  overflow: hidden;
  padding: 0.75rem;
}

.face-card__value {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
Enter fullscreen mode Exit fullscreen mode

The important part is reserving a stable amount of space. If a card grows every time a long string appears, nearby buttons move and the interface becomes difficult to scan.

There is a trade-off between truncating and wrapping. Wrapping preserves more of the value, but it makes rows uneven. Truncation produces a calmer grid, provided the complete value is still available through the copy action, an accessible label, or a detail view.

I also avoid forcing a decorative font. A system fallback stack is more honest because it lets the operating system choose a font with better coverage for the characters it actually supports.

Search and filtering should compose

Search and category filters should not compete with each other. A useful pipeline is:

const visibleFaces = faces
  .filter((face) => selectedCategory === "all" || face.category === selectedCategory)
  .filter((face) => matches(face, query))
Enter fullscreen mode Exit fullscreen mode

This order is easy to understand and keeps the UI predictable. Changing the search text should not silently clear the selected category, and changing the category should not discard the query unless that behavior is explicitly communicated.

For a small collection, client-side filtering is perfectly adequate. For a larger one, the same data model can support server-side search or indexed static pages later. Starting with a clean boundary keeps that decision reversible.

Accessibility is part of the copy action

Each item should have a real button, not a clickable div. The button needs a visible focus style, a useful accessible name, and a result that is announced after activation.

If the text face itself is decorative in the visual layout, aria-hidden="true" can prevent a screen reader from reading a long sequence of punctuation twice. The accessible name should still identify the action and the value being copied.

Keyboard users should be able to reach every item and activate it with Enter or Space. On touch devices, the hit target should be large enough to use without precision tapping.

These details are easy to skip because the interface looks small. They matter more when the main purpose of the page is a repeated action performed many times.

Test the real characters

Snapshot tests can confirm that a string is present, but they will not tell you whether it wraps badly on a narrow screen or whether a particular font renders a symbol as a missing glyph.

For this kind of UI, I would test at three levels:

  • Unit tests for filtering, normalization, and copy-state transitions.
  • Browser tests for keyboard activation and clipboard success or failure.
  • Visual checks on a desktop and a narrow mobile viewport using representative short and long values.

The test data should include combining marks, full-width punctuation, unusual symbols, and strings that are much longer than the average item. The edge cases are the content.

If you want to inspect a working example while experimenting with these patterns, the kaomoji collection used for this project keeps the content searchable and each text face directly copyable. The implementation choices are more important than the subject matter: the same interaction model works for any small text utility.

Final takeaways

A copy-friendly text interface is less about rendering a list and more about respecting the value being copied.

  • Keep the original string as the single source of truth.
  • Normalize search input carefully, but preserve the original value for copying.
  • Show clear success and failure states for clipboard actions.
  • Reserve stable space for variable-width Unicode content.
  • Use real buttons, focus styles, accessible names, and live status feedback.
  • Test characters and layouts that are likely to break assumptions.

Unicode gives us a surprisingly expressive medium without images or custom assets. A small amount of deliberate UI design is what makes that medium feel reliable.

Top comments (0)