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.