Back to Blog

Tailwind v4: Icons Not Showing After Upgrade? bg-opacity-* Was Removed

July 27, 20268 min read

We upgraded a production Next.js app from Tailwind CSS 3.4 to 4.3 last week. The official codemod ran, the build passed, TypeScript passed, and every structural check we threw at the output came back green. Then someone opened the contact page and four icons were simply gone — replaced by solid teal circles.

The icons were still in the DOM. The classes were still in the HTML. Nothing had errored anywhere in the pipeline. The cause was a family of utilities Tailwind v4 removed, that the official upgrade tool does not rewrite, and that fail in the quietest way possible: by doing nothing at all.

Here is the symptom, the reason no tooling catches it, the one command that finds every instance, and — importantly — the second bug you will introduce if you fix it with a naive find-and-replace.

Why your icons disappeared after upgrading to Tailwind v4

The symptom presents differently depending on what was sitting inside the element, so you may have arrived here searching for any of these:

  • Icons not showing — an icon inside a tinted circle or square vanishes completely
  • Backgrounds suddenly too dark or too saturated — a soft tint renders at full strength
  • Overlays fully opaque — a modal backdrop that was semi-transparent now blacks out the page
  • Badges and pills losing their tint — light status chips become solid blocks of colour

All four are the same bug. Whether you notice a missing icon or an over-saturated panel depends only on whether the element inside shared the background's colour.

The markup was ordinary v3:

<div class="w-16 h-16 bg-brand bg-opacity-10 rounded-full flex items-center justify-center">
  <Phone class="w-8 h-8 text-brand" />
</div>

In v3 that renders a pale 10%-opacity circle with a solid brand-coloured icon sitting on it. In v4 bg-opacity-10 does not exist, so it contributes nothing. bg-brand then paints the circle at full strength — the exact same colour as the icon on top of it. The icon is still rendered, still 24×24, still in the accessibility tree. It is just brand-on-brand, and therefore invisible.

That is the fingerprint worth memorising: a container renders at full colour saturation when it used to be a soft tint, and whatever sat inside it disappears. If your icon and your background were both drawn from the same palette entry, the element vanishes entirely. If they differed, you instead get a jarringly over-saturated badge, pill, or overlay — easy to skim past in review.

Why nothing in your pipeline catches it

This is the part that makes it expensive. Tailwind does not error on unknown class names — it simply generates no CSS for them. So:

  • The build passes. There is no such thing as an invalid utility; there are only utilities that produce no rules.
  • TypeScript passes. These are strings in className. Nothing type-checks them.
  • ESLint passes. No rule is watching for removed Tailwind utilities.
  • Structural assertions pass. We compared rendered HTML against production: identical icon counts, identical layout classes, identical DOM structure. All green — because the classes are present in the markup. They just stopped meaning anything.
  • The CSS bundle grows, not shrinks. Ours went from 35,074 to 48,188 bytes. If you are watching for a suspiciously small stylesheet as a canary, this will not trip it.

Every automated signal we had said the migration was clean. A human looking at one page is what caught it.

What actually changed

Tailwind v3 implemented opacity through a CSS custom property. bg-opacity-10 set --tw-bg-opacity: 0.1, and bg-brand emitted a colour that consumed that variable. Two classes, cooperating.

Tailwind v4 replaced that with the slash syntax — bg-brand/10 — as a single self-contained utility, and dropped the standalone *-opacity-* classes entirely. That is a reasonable simplification. The problem is purely one of migration.

The surprising part: the codemod renames plenty, just not these

npx @tailwindcss/upgrade is genuinely good. On our codebase it rewrote the @tailwind directives to @import "tailwindcss", migrated the JS theme config into a CSS @theme block, swapped the PostCSS plugin to @tailwindcss/postcss, removed autoprefixer, and renamed a pile of utilities across 51 template files. Concretely, in our repo it converted:

  • 107 instances of flex-shrink-0shrink-0
  • 47 instances of shadow-smshadow-xs
  • 88 instances of outline-noneoutline-hidden

So it is clearly willing and able to rewrite utility names at scale. And yet, in that same run, it left 116 opacity utilities across 41 files completely untouched:

  48  bg-opacity-10
  29  bg-opacity-20
  22  bg-opacity-5
   7  bg-opacity-50
   2  bg-opacity-30
   2  text-opacity-90
   2  text-opacity-80
   1  bg-opacity-75
   1  bg-opacity-40
   1  ring-opacity-50
   1  border-opacity-20

Do not assume that a clean codemod run means the opacity utilities were handled. Check explicitly.

Find every instance

One command, run it before you congratulate yourself on a smooth migration:

grep -rnE '\b(bg|text|border|ring|divide|placeholder)-opacity-[0-9]+' \
  app components lib src

Any output at all means you have latent breakage. Note that all six prefixes matter — we found bg-, text-, border- and ring- variants in a single mid-sized codebase, and it is easy to grep for only bg-opacity and declare victory.

The conversion

Merge the opacity class into the colour class of the same kind:

bg-brand bg-opacity-10        →  bg-brand/10
text-white text-opacity-90    →  text-white/90
border-brand border-opacity-20 →  border-brand/20
ring-brand ring-opacity-50    →  ring-brand/50

Variant-prefixed ones need the variant carried onto the colour, which is where hand-editing beats scripting:

bg-white bg-opacity-50 hover:bg-opacity-75
    →  bg-white/50 hover:bg-white/75

The trap inside the fix

This is the part I have not seen written down anywhere, and it cost us a second round of debugging.

The obvious automation is: find text-opacity-80, look for a nearby text-* class in the same className, and merge them. That logic is wrong, because text- is not a colour namespace. It is shared by text alignment, font size, overflow and wrapping utilities.

Our script hit this markup:

<div class="mt-6 text-center text-sm text-white text-opacity-80">

It matched the first text-* class it found — text-center — and produced:

<div class="mt-6 text-center/80 text-sm text-white">

text-center/80 is not a valid utility, so the centering silently disappeared. We had traded an invisible-icon bug for an off-centre-text bug, and once again nothing errored. The correct output is text-center text-sm text-white/80.

If you script this, exclude the non-colour text-* utilities explicitly — center, left, right, justify, start, end, xs, sm, base, lg, xl, 2xl through 9xl, ellipsis, clip, wrap, nowrap, balance, pretty. The same caution applies to bg- (bg-cover, bg-center, bg-no-repeat) and border- (border-2, border-solid, border-t).

Then audit your own output. We ran a second pass looking for any /number attached to something that is not a colour, which surfaced both broken instances immediately:

grep -rnE '\b(bg|text|border|ring)-(center|left|right|justify|xs|sm|base|lg|xl|[2-9]xl|cover|contain|solid|dashed|[0-9]+)/[0-9]+' \
  app components lib src

How to actually verify

The lesson we took from this is that checking whether a class is present proves nothing in Tailwind v4, because presence and effect have been decoupled. Assertions over rendered HTML — class counts, DOM structure, element counts — will all sail straight past this bug.

What worked was comparing rendered pages against a known-good reference. We screenshotted each page from the new build and from the still-running old version, then diffed them pixel by pixel. That is what caught the off-centre text: an 85-pixel horizontal shift in one line of small print that had already been eyeballed and declared fine. Headless Chrome will do it with no test framework at all:

chrome --headless --screenshot=new.png --window-size=1400,2400 http://localhost:3000/page
chrome --headless --screenshot=old.png --window-size=1400,2400 https://production.example.com/page

Compare the two, and treat any region of difference as something to explain rather than something to wave through. Ours produced exactly three explanations: the fixed icons, the fixed text alignment, and one genuine v4 behaviour change — gradients now interpolate in OKLAB rather than sRGB, so gradient midpoints shift slightly. That last one is expected, not a bug, and you can opt out with an interpolation suffix such as bg-linear-to-r/srgb if you need pixel parity with your v3 output.

The short version

  • Tailwind v4 removed bg-opacity-*, text-opacity-*, border-opacity-*, ring-opacity-*, divide-opacity-* and placeholder-opacity-*.
  • The official upgrade tool does not rewrite them, despite renaming many other utilities in the same run.
  • They fail silently: no build error, no type error, no lint error, and a larger CSS bundle.
  • The fingerprint is a container at full colour saturation where a soft tint used to be — and anything the same colour inside it disappearing.
  • Grep for all six prefixes, convert to slash syntax, and beware that text- and bg- are shared with non-colour utilities.
  • Verify by diffing rendered pages against the old build, not by asserting that classes exist.

None of this is a defect in Tailwind v4. The slash syntax is better than two classes silently cooperating through a custom property. But a removal that produces no error, in utilities the codemod skips, is a combination worth an explicit check on your side — because your build will tell you everything is fine.

Need help building this?

I turn ideas like these into production-ready software. Let's talk about your project.

Get a Free Quote