“I think I’m done with reality.”
— The Seventh Circle by Architects
We’ve all, at some point, had the thought that CSS sucks. Indeed, the overhyped buzz around the new pretext.js library as a “CSS killer” reflects how much we all want to strangle CSS at times
Someday in the future, CSS might answer back: “No, you are the one who sucks at CSS. Here’s the CSS Parser API. Go make your own styling language and see how close any alternative is to perfect.”
Well, CSS, you’ve been teasing me since 2017 with the possibility of that API, which I hoped would let me create my own CSS syntax, but no such thing materialized.
And while I am venting, since 2003 we’ve asked over and over and over for ::nth-letter, which seems like a natural suggestion. I mean, we’ve always had ::first-letter to mimic print effects like drop caps, so we know you could do ::nth-letter if you wanted.
You are just a tease, CSS, which means that in 2026, I still can’t write styles like Chris Coyier’s hypothetical example from back in 2011.
h1.fancy::nth-letter(n)
display: inline-block;
padding: 20px 10px;
color: white;
h1.fancy::nth-letter(even)
transform: skewY(15deg);
background: #C97A7A;
h1.fancy::nth-letter(odd)
transform: skewY(-15deg);
background: #8B3F3F;
Impossible demos of ::nth-letter
If you prefer to play with an interactive example, here is the invalid syntax ::nth-letter working in CodePen.
And here’s a video demo by my eight-year-old, to demonstrate that using this syntax is child’s play.
If ::nth-letter existed, we could migrate my text vortex scrolling effect to use it, and then delete the JavaScript, as seen below. This is Chrome/Safari-only, due to the use of the new sibling-index() function.
If we had ::nth-letter, we could migrate Temani Afif’s amazing direction-aware elastic hover, then gleefully delete all the spans in the original markup around each letter. The ::nth-letter code would be as shown in the CodePen below.
If only ::nth-letter existed, I might make it my mission to go around upgrading every typography styling demo to use it.
Alas, the syntax to make this work is not possible with CSS and HTML. Such capabilities exist only in the wildest realms of our imagination. Article ends here.
Wait, what? How do all those demos work?
While we’re on the topic of doing the impossible, it has been said — by Philip Walton at Google, who tried really hard in the past to make production-ready CSS polyfills — that it is not possible to write a reliable polyfill for CSS. He gave up the idea, but I like to imagine his nickname at Google became “Polyphil,” so it wasn’t a total loss.
Philip also created this abandoned framework for creating CSS polyfills, which still works, although it’s so old that the examples show how to polyfill flexbox. In the decade since he stopped supporting this library, it doesn’t seem like the feasibility of perfect CSS polyfills has improved.
However, Philip’s findings haven’t stopped cool CSS polyfills from existing. They can be useful, even if they can’t be perfect. Perfect is the enemy of good.
Why we’re not going to give up on ::nth-letter
To maintain our motivation for simulating ::nth-letter, I note that the lack of a spec might make implementing it easier than writing a true polyfill. Anything we create in this space will technically be a shim rather than a polyfill. All polyfills are shims, but not all shims are polyfills — like all cows are animals, but not the other way around.
We’re patching CSS to add functionality that never existed, whereas a polyfill simulates a feature that exists in certain environments, and/or at least has a formal spec. The closest we got to a draft spec was experimental work Adobe attempted in WebKit back in 2012, which never got anywhere.
Having explained that, I will use the terms polyfill and shim interchangeably here, because polyfill is the more well-known term, and because I am anyhow about to play fast and loose with what words mean.
Defining our terms
Since nobody knows how ::nth-letter would behave, I can make up my own answers to questions like those Jeremy Keith raised about how it would even work.
As Humpty Dumpty said, the words will mean what I want them to mean.
1. What does “nth” mean?
Jeremy wondered what the third letter in a paragraph would be. Take this example markup:
<p>AB<span>CD</span>EF</p>The third letter could be:
- “C” because that’s the third letter as it would appear when you read from left to right, regardless of the DOM structure. After all,
p::first-letterwould select “A,” even if that character was deeply nested in markup within the paragraph. - “E” because that’s what
:nth-childwould do. E is the third direct child of the paragraph element. - “D” or “B” if we styled the paragraph to use a right-to-left writing direction. In a more probable scenario, if the paragraph above were changed to
<p>אב<span>קד</span>פע</p>Hebrew characters are inherently right-to-left in Unicode — and then the answer would be different again.
The answer, in the universe I created for this article, is that ::nth-letter will behave the same as :nth-child, which depends on the source order of the direct child of the element.
Isn’t life simpler when the rigorous drafting process of the W3C is replaced with the whims of a lone crackpot?
2. What does “letter” mean?
We touched on how other languages would affect ::nth-letter. Only half of the web uses English. If we are simulating a browser feature, we can’t ignore other languages, can we?
Not only are writing directions different in languages other than English, but some languages use multiple characters to represent a single letter. Now, in theory, ::first-letter selects all parts of such a letter. But the browser support for that is poor. ::first-letter has some other interesting edge cases I wouldn’t have expected, such as selecting punctuation together with the first letter, maybe because that’s how drop caps are normally presented.
At this point, I decide that any answer I give would disappoint some people if their idea of a letter isn’t what’s selected by ::nth-letter. To circumvent this debate, let’s say ::nth-letter is an alias for the nth character.
A bit extreme, but the examples I showed above of how people imagine ::nth-letter don’t seem to focus on whether each character is a letter. And I think my 8-year-old would have been disappointed if the exclamation point he added to his rainbow text wasn’t colored.
Look, if you don’t like it, go back to your own universe where there’s no ::nth-letter at all. Or you can tinker with the source code I will show you next.
How to write an impossible polyfill
I published this experimental library on npm. That’s what the above CodePen uses via unpckg. The ::nth-letter package received 1.3k downloads in its first week without me advertising it, so that was nice.
Instead of trying to build a perfect polyfill, there’s a certain freedom in knowing we can’t. We’ll therefore do the simplest thing that could possibly work. We rewrite the CSS and transform the DOM so the browser can do the rest. Here’s a simplified version that is 29 lines of JavaScript and works in today’s browsers. As we explore how it works, you’ll see that the brevity is achieved by leveraging what CSS can already do with minimal tampering.
import getCssData from 'get-css-data';
import SplitText from 'gsap/SplitText';
getCssData(
onComplete(cssText, cssArray, nodeArray)
nodeArray.forEach(e => e.remove());
const selectors = new Set();
const nthArgs = new Set();
cssText = cssText.replace(//*[sS]*?*//g, '');
// Replace ::nth-letter with :nth-child in CSS
let rewrittenCss = cssText.replace(
/([^,rn]+?)::?nth-letter[ t]*(([^n)]*))/gi,
(full, selector, args) =>
selector = selector.trim();
selectors.add(selector);
nthArgs.add(args);
// Use :nth-child instead of ::nth-letter
return `$selector .char:nth-child($args)`;
);
document.head.insertAdjacentHTML("beforeend", `<style>$rewrittenCss</style>`);
selectors.forEach(selector =>
document.querySelectorAll(selector).forEach(el =>
if (el.hasAttribute('data-nth-letter')) return;
el.setAttribute('data-nth-letter', 'attached');
new SplitText(el, type: 'chars', charsClass: 'char' );
);
);
);A lot is going on in this small block of code, so let’s break down the phases.
Translating ::nth-letter into valid CSS
Even at this first phase, we get a sense that introducing custom CSS syntax won’t be as easy as we might hope. It’s less conveniently obvious how to do it than monkey patching JavaScript, although the risks are comparable to patching globals in JavaScript.
The way CSS is applied to a web page doesn’t provide a good opportunity to intercept standard CSS behaviors and customize them.
Indeed, even making the nonstandard ::nth-letter syntax available to our JavaScript code is tricky, because the CSS parser will discard invalid CSS, so if the user includes the selector .rainbow::nth-letter(2n), that won’t be available to JavaScript when it accesses the stylesheets property of the DOM.
We need to gather all raw CSS free from judgment of validity, so let’s use get-css-data, which concatenates the raw contents of any style tags in the DOM and uses fetch to include the contents of each stylesheet imported via link tags.
Sidenote: get-css-data won’t work if the CORS policy doesn’t allow it, but that is one of the inherent limitations of CSS polyfills.
Next, we rewrite the nonstandard CSS using regular expressions, which is a bit ghetto. A more rigorous approach would use something like PostCSS at build time. But, we can get away with regex in this case, because we’re not doing our own parsing of CSS; we’re doing a relatively simple find-replace, which regex is good at.
The result of the replacement will translate the invalid CSS…
.rainbow::nth-letter(n)
color: #f432a0;
…into this valid CSS:
.rainbow .char:nth-child(n)
color: #f432a0;
This great video concludes that the least bad option for implementing a CSS polyfill is to “rewrite the CSS to target individual elements while maintaining cascade order.” Philip adds that he has “never seen a polyfill do this. I don’t recommend it, but I think it’s the best of the bad options.” Better late than never to create a polyfill using this strategy.
Implementing the translator for ::nth-letter
The shim removes the original styles from the page and replaces them with the rewritten styles, like so:
getCssData(
onComplete(cssText, cssArray, nodeArray)
nodeArray.forEach(e => e.remove());
const selectors = new Set();
const nthArgs = new Set();
cssText = cssText.replace(//*[sS]*?*//g, '');
// Replace ::nth-letter with :nth-child in CSS
let rewrittenCss = cssText.replace(
/([^,rn]+?)::?nth-letter[ t]*(([^n)]*))/gi,
(full, selector, args) =>
selector = selector.trim();
selectors.add(selector);
nthArgs.add(args);
// Use :nth-child instead of ::nth-letter
return `$selector .char:nth-child($args)`;
);
document.head.insertAdjacentHTML("beforeend", `<style>$rewrittenCss</style>`);
);At this point, we have translated the unsupported ::nth-letter syntax into valid CSS. But it still needs some DOM elements to style, or it won’t do anything.
Preparing the DOM
Since ::nth-letter doesn’t exist, my implementation is ultimately a convenient abstraction for what I did manually in my spiral scrollytelling article. So, after gathering all the elements that require styling of individual characters, we split the targeted content into div tags, using the freely available SplitText plugin from GSAP.
selectors.forEach(selector =>
document.querySelectorAll(selector).forEach(el =>
if (el.hasAttribute('data-nth-letter')) return;
el.setAttribute('data-nth-letter', 'attached');
new SplitText(el, type: 'chars', charsClass: 'char' );
);
It works! The auto-magically generated CSS receives an auto-magically generated DOM to style. We all live happily ever after. Article over for real this time.
Or is it?
Do we have to modify the DOM for this?
As mentioned in a 2021 CSS-Tricks newsletter that lamented ::nth-letter being “sadly still not a thing,” the solution of spitting the text into separate elements per character is “pretty gross, right? It’s a shame that we have to mess up the markup to make a relatively simple aesthetic change.”
The same post spoke of a potential accessibility issue if you split characters into their own elements: “screen readers (some, anyway?) read each of those characters with pauses in between.” Research shows that VoiceOver can cause this issue, although it’s reported that the role attribute can now alleviate it. The SplitText plugin I use also automatically accounts for accessibility, but it may not work on all screenreaders, and sadly, accessibility for split text is harder to get right than you’d think.
Also, if ::nth-letter were a native feature, it would be a pseudo-element. It would be great if we could simulate that, knowing there is a risk we will trip over those extra elements that my library adds to the DOM.
A pseudo-element could give us the best of both worlds for solving the task at hand: something that is purely presentational and doesn’t pollute the DOM, but can still behave like part of the DOM for styling purposes only. Can we implement something similar to avoid polluting our DOM?
Yes and no.
The harsh truth is we may never be able to implement our own custom pseudo-elements.
Earlier, I expressed the hope that the CSS Parser API would someday help, but even in the unlikely event that this API materializes, the intent wouldn’t be to allow developers to implement their own CSS syntax or pseudo-elements. As you can see from this 2021 unofficial draft, if we ever get this API, it would likely expose the browser’s CSS parser for programmatic use — but it probably wouldn’t help us customize how CSS is interpreted. Custom pseudo-elements would be the domain of a hypothetical CSS Renderer API, which is something my brain just came up with that nobody has even proposed.
Bramus from the Chrome team has a draft document outlining how a CSS parser extensions API would work, and this is closer to what I imagined the hypothetical CSS parser API might provide, but Bramus’s document doesn’t currently discuss custom psuedo-elements. There is also the HTML-in-canvas API proposal which would let us customize the way elements are rendered without modifying their DOM. That’s already experimentally available in Chrome, but still wouldn’t give us custom psuedo-elements we could arbitrarily style using CSS.
Shadow DOM version of ::nth-letter
If we’re stuck with manipulating the DOM, the closest we can get to custom pseudo-elements is to hide the character elements in the shadow DOM of the targeted elements, while exposing an API that lets us style selected characters from outside the target.
If we are determined that targeted elements of this new selector won’t pollute the light DOM with extra markup, then we have to hide that markup in the shadow DOM. If we do that, then the closest I know of to a custom pseudo-element is the ::part pseudo-element. If we use that, then by design, we can’t use:
.container::part(character):nth-child(2)
color: red;
The reason is that the shadow DOM of my element would look like:
<div part="character">1</div>
<div part="character">2</div>A consumer of my component shouldn’t be able to know the structure of the shadow DOM from outside the component using CSS. That’s why “structural pseudo-classes that match based on tree information, such as :empty and :last-child, cannot be appended“ to ::part. Once upon a time, there was a ::shadow pseudo-element that would have let us style :nth-child from outside the shadow DOM, but it was deprecated a lifetime ago.
Actually, there is a way to still use :nth-child together with ::part if you think laterally.
What if we populate each character’s ::part attribute based on the :nth-child selectors we know we will need to support? We know what those are, since we created them when we were regex replacing the styles!
Then we’d have:
.rainbow::part(nth-child(n))
color: #f432a0;
And the HTML in our shadow DOM would look something like:
<h1 class="rainbow" data-nth-letter="attached">Rainbow</h1>
Rainbow
#ShadowRoot
<span aria-hidden="true" aria-label="Rainbow">
<div class="char" aria-hidden="true" part="nth-child(n) nth-child(odd)">R</div>
<!-- etc. -->
</span>We can generate such a shadow DOM using the following slightly more complex version of the JavaScript:
import getCssData from 'get-css-data';
import SplitText from 'gsap/SplitText';
getCssData(
onComplete(cssText, cssArray, nodeArray)
nodeArray.forEach(e => e.remove());
const selectors = new Set();
const nthArgs = new Set();
// Remove CSS comments
cssText = cssText.replace(//*[sS]*?*//g, '');
let rewrittenCss = cssText.replace(
/([^,rn]+?)::?nth-letter[ t]*(([^n)]*))/gi,
(full, selector, args) =>
selector = selector.trim();
selectors.add(selector);
nthArgs.add(args);
return `$selector::part(nth-child\($CSS.escape(args)\))`;
);
document.head.insertAdjacentHTML("beforeend", `<style>$rewrittenCss</style>`);
selectors.forEach(selector =>
document.querySelectorAll(selector).forEach(el => el.hasAttribute('data-nth-letter')) return;
const shadow = el.attachShadow( mode: "closed" );
el.setAttribute('data-nth-letter', 'attached');
const wrapper = document.createElement("span");
wrapper.setAttribute('aria-hidden', 'true');
wrapper.innerHTML = el.innerHTML;
shadow.appendChild(wrapper);
const split = new SplitText(wrapper, type: "chars", charsClass: "char" );
nthArgs.forEach((arg, i) =>
let chars = wrapper.querySelectorAll(`.char:nth-child($arg)`);
chars.forEach(c => );
);
);
);
);By pre-calculating the :nth-child selectors as names of the shadow parts which match the ::nth-letter usages our CSS has requested, we can select them from outside, without touching the light DOM, and without hitting a brick wall of the intentional limitations of shadow DOM.
It works! Are we there yet? Is the best answer to use shadow DOM?
Not really, it causes at least two big issues:
- This version won’t work on elements that don’t support attaching a shadow DOM, such as
<a>or<p>. - We can’t use the emergent
sibling-index()function in the styles for a shadow part, becausesibling-index()relies on knowing the structure of the DOM, just like:nth-childdoes. This prevents supporting the text styling demos I showed at the start. These demos would not work with the shadow DOM version of::nth-letter.
I notice that ::first-letter is also seriously limited in the styling it supports. That’s not enough reason to knowingly cripple our implementation of ::nth-letter when there’s an option not to. I conclude the light DOM version is better. It might be “gross” markup, but at least we are no longer the ones who need to write or maintain it. And if browsers ever support ::nth-letter natively, the design of the shim is intended so we‘d keep the CSS as-is, delete the reference to my library, and never speak of it again.
The (actual) ending
Now that we have a simple basis for implementing things like ::nth-letter, it would be feasible to add ::nth-word, ::nth-last-letter, and so on. Chris Coyier showed cool use cases for those in [his call for ::nth everything.
There are still many limitations to the ::nth-letter shim, such as:
- It doesn’t work if you change the DOM or the styles on the fly, although we probably could support that.
- It doesn’t work if you use
::nth-letterin a CSS selector passed toquerySelectorAll, although we could monkey-patch JavaScript to make that work. - I am unsure how scalable it is.
- It could lead to hard-to-diagnose bugs because it rewrites all the CSS and adds unexpected “char” divs to the DOM. I noticed that Philip Schatz’s polyfill for a crazy working draft called the “CSS Generated Content Module” requires the consumer to opt-in by using special attributes on the
linkorstyletags. That’s an interesting compromise that might limit the blast radius by only triggering the CSS rewrites where we need them, but it seems less convenient than just referencing the library and then using the new syntax. - External stylesheets not allowed by CORS won’t work.
In summary, I’d probably use ::nth-letter and its hypothetical friends all the time if these features were built into browsers. But I must admit that, having explored the complexity of building generic support for a design we can often adequately solve with a few lines of JavaScript, I see why the browsers are reluctant to implement and maintain such a feature.
My shim might give the powers that be another reason to say native support isn’t necessary, or if lots of people use my ::nth-letter hack in the wild, the browser gods might recognize the need to implement it for real.
Either way, let’s never argue again, CSS. I understand now why you did what you did. I could never stay mad at you.
Let’s Use the Nonexistent ::nth-letter Selector Now originally handwritten and published with love on CSS-Tricks. You should really get the newsletter as well.