Polyfilling the ::nth-letter selector with a JavaScript shim

The standard advice for the missing ::nth-letter selector is to wrap every character in a span by hand, which costs you developer time and rendering speed at once. I have opened projects where the DOM carried thousands of spans and the browser’s rendering engine more or less gave up. The W3C has been dangling this since 2003, so anything usable today has to be built.

I have been wrestling with WordPress since the 4.x days, and waiting for native support has never paid off yet. Complex modern CSS features or a drop cap that refuses to behave, either way you need something that runs in the browsers people actually have. A shim can get close to native without turning into a maintenance problem.

Why the ::nth-letter selector is hard to fake

The native ::first-letter pseudo-element is useful, and it is also alone out there. There has never been a way to target the third character, or every even one, without polluting clean semantic HTML. CSS parsers discard whatever they do not recognize. Write .fancy::nth-letter(2) and the browser does not merely ignore the declaration, it throws the whole rule away before your JavaScript can find it in document.styleSheets.

So the shim has to intercept the raw CSS text, rewrite those selectors into something the browser does understand (:nth-child), then reshape the DOM to match. That means splitting the text into individual elements without breaking accessibility on the way.

Implementing the polyfill

Here is the shim I use, cleaned up a little. A small library pulls the CSS data and GSAP’s SplitText does the DOM work. Watch the “char” class injection, because that is where most people hit a race condition by touching the DOM before the styles have been processed.

import getCssData from 'get-css-data';
import { SplitText } from 'gsap/SplitText';

// We fetch raw CSS to prevent the browser from discarding the ::nth-letter selector
getCssData({
  onComplete(cssText, cssArray, nodeArray) {
    nodeArray.forEach(e => e.remove());
    const selectors = new Set();
    
    // Regex to rewrite our custom syntax into valid child selectors
    let rewrittenCss = cssText.replace(
      /([^,{{\r\n]+?)::?nth-letter[ \t]*\(([^\n)]*)\)/gi,
      (full, selector, args) => {
        selector = selector.trim();
        selectors.add(selector);
        return `${selector} .char:nth-child(${args})`;
      }
    );

    document.head.insertAdjacentHTML("beforeend", `<style>${rewrittenCss}</style>`);

    // Transform the DOM to match the new CSS structure
    selectors.forEach(selector => {
      document.querySelectorAll(selector).forEach(el => {
        if (el.hasAttribute('data-nth-letter')) return;
        el.setAttribute('data-nth-letter', 'attached');
        
        // GSAP handles the accessibility aspect by default
        new SplitText(el, { type: 'chars', charsClass: 'char' });
      });
    });
  }
});

The accessibility problem

The markup is the ugly part. Splitting characters into individual <div> or <span> tags can confuse screen readers and turn a plain word into a spelled-out mess. That is the reason to reach for SplitText instead of rolling your own splitter: it adds aria-label to the parent and hides the split characters from assistive technology.

Building a smarter CSS date range selector or a heavily visual landing page, you end up weighing the same trade-offs. In my experience the light DOM version bends where you need it to, while the Shadow DOM approach falls over on plain paragraphs and anchors.

If the ::nth-letter selector is eating your dev hours, hand it to me. I have been wrestling with WordPress since the 4.x days.

Where this leaves you

None of this should be necessary for a ::nth-letter selector, but the browser engines have not moved and the shim works. It respects the cascade and leaves your markup relatively clean until the styles kick in. Enqueue the scripts properly, and keep an eye on CORS when the stylesheet lives on another domain.

author avatar
Ahmad Wael
I'm a WordPress and WooCommerce developer with 15+ years of experience building custom e-commerce solutions and plugins. I specialize in PHP development, following WordPress coding standards to deliver clean, maintainable code. Currently, I'm exploring AI and e-commerce by building multi-agent systems and SaaS products that integrate technologies like Google Gemini API with WordPress platforms, approaching every project with a commitment to performance, security, and exceptional user experience.