We need to talk about the state of typography on the web. For some reason, the standard advice for the missing ::nth-letter selector has become “just wrap every character in a span manually,” and it’s killing both developer productivity and site performance. I’ve seen projects where the DOM was so bloated with thousands of spans that the browser’s rendering engine essentially gave up. It’s a mess, and while the W3C has been teasing us since 2003, we need a pragmatic solution today.
As someone who has been wrestling with WordPress since the 4.x days, I’ve learned that waiting for native support is a losing game. Whether you are building complex modern CSS features or just trying to get a drop cap to behave, you need tools that work in the current browser landscape. Let’s look at how we can hack together a shim that feels native without the maintenance nightmare.
The Problem with the ::nth-letter Selector
The native ::first-letter pseudo-element is great, but it’s a lonely island. We’ve always wanted a way to target the third, fifth, or every even character without polluting our clean semantic HTML. The issue is that CSS parsers are designed to discard anything they don’t recognize. If you write .fancy::nth-letter(2), the browser doesn’t just ignore it—it throws the entire rule away before your JavaScript even gets a chance to see it in the document.styleSheets object.
To fix this, we have to intercept the raw CSS text, rewrite the selectors into something the browser understands (like :nth-child), and then transform the DOM to match. Specifically, we need to split our text into individual elements while maintaining accessibility.
Implementing the Polyfill
Below is a refactored version of the shim I use. It leverages a small library to grab the CSS data and GSAP’s SplitText for the heavy DOM lifting. Notice how we handle the “char” class injection—this is where most developers trigger a race condition by trying to manipulate the DOM before the styles are 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' });
});
});
}
});
Accessibility: The Elephant in the DOM
The “gross” part of this approach is the markup. Splitting characters into individual <div> or <span> tags can confuse screen readers, turning a simple word into a spelled-out nightmare. This is why using a tool like SplitText is critical—it automatically adds aria-label to the parent and hides the split characters from assistive technology.
Furthermore, if you’re building a smarter CSS date range selector or a highly visual landing page, you have to balance these trade-offs. I’ve found that the light DOM version is far more flexible than the Shadow DOM approach, which fails on basic elements like paragraphs and anchors.
Look, if this ::nth-letter selector stuff is eating up your dev hours, let me handle it. I’ve been wrestling with WordPress since the 4.x days.
The Final Takeaway
We shouldn’t have to jump through these hoops for a ::nth-letter selector, but until the browser engines catch up, this shim is your best bet. It’s technically precise, respects the cascade, and keeps your markup (relatively) clean until the styles kick in. Just remember to enqueue your scripts correctly and watch out for those CORS issues on external stylesheets. Ship it.