Style the Exception with Tailwind v4's not-* Variant

The Tailwind not-* variant compiles to CSS :not(), so you can style every element except the exception with no specificity overrides or custom CSS at all.

Steven Richardson
Steven Richardson
· 6 min read

Every list of dividers I have ever built starts the same way. Border on every row, then a :last-child override to peel the last one back off. In a stylesheet that is two rules. In utility classes it is a base utility fighting a last: utility, and the override always feels backwards — you add the style to everything, then subtract it from the one element that never wanted it.

Tailwind v4 flips that around. The not-* variant compiles straight to the CSS :not() pseudo-class, so you can say "border on all but the last" in a single utility and skip the override entirely. No dropping into a scoped CSS file with @reference, no Alpine conditional.

What the not-* variant compiles to#

The rule is simple: not-{variant} wraps whatever {variant} would generate in a :not(). Tailwind's own reference table spells it out — not-[...] maps to &:not(...).

So not-last:border-b is not magic. It compiles to roughly this:

/* not-last:border-b */
&:not(:last-child) {
  border-bottom-width: 1px;
}

last: on its own targets :last-child. Wrap it in not- and you target :not(:last-child) — every element that is not the last child. That is the whole idea behind the tailwind css not selector. Once you internalise "not- negates the selector it wraps", the rest is composition.

Negating states with not-* (hover, focus, and active)#

Where the tailwind v4 not variant earns its place is stacking with other state variants. Say you want a button to darken on hover, but not while it is being pressed. Without negation you need a separate active: utility to undo the hover colour. With not-* you compose the two:

<button class="bg-zinc-800 hover:not-active:bg-zinc-700">
  Save
</button>

Read it left to right: on hover, but not while active, use bg-zinc-700. The official docs use the same shape with focus — hover:not-focus:bg-indigo-700 applies the hover colour only when the element is not focused.

not-hover: works standalone too. not-hover:opacity-60 dims an element whenever it is not being hovered, which reads more naturally than styling the default state and overriding it on hover. This is the same variant-composition engine that powers CSS-first dark mode with @custom-variantnot-* just adds a negation in front.

Negating structure, media, and supports queries#

Structural variants negate the same way. not-first: is :not(:first-child), not-last: is :not(:last-child), and both are cleaner than the base-plus-override pattern for dividers and spacing.

Negation also reaches up into media and feature queries, which is where it stops being sugar and starts doing things a plain override cannot. not-supports-[...] styles an element only when a feature is not supported:

<div class="grid grid-cols-3 gap-4 not-supports-[display:grid]:block">
  <!-- ... -->
</div>

If the browser understands display: grid, it stays a grid. If it does not, the not-supports-* utility drops it back to block. That is a real progressive-enhancement fallback — handy when you lean on newer layout like subgrid or container queries and want a floor for older browsers. There is a matching not-forced-colors: for Windows High Contrast, and you can negate a media query the same way — for example, only apply an effect when the user has not asked to reduce motion.

A practical "all but the last" example#

Here is the before and after that made me switch. A stacked list with dividers between rows but not after the last one.

Before — a border on every child, then an override to remove it:

<ul>
  <li class="border-b border-zinc-200 last:border-b-0 py-3">Item</li>
  <!-- last:border-b-0 undoes the border you just added -->
</ul>

After — state the intent once:

<ul>
  <li class="not-last:border-b border-zinc-200 py-3">Item</li>
  <!-- border on every row that is not the last -->
</ul>

The second version has one border utility instead of two, and it says what you mean: a divider on everything but the last row. The same trick cleans up button groups (not-last:border-r), breadcrumb separators, and any tailwind style all but one layout you would otherwise reach for custom CSS to solve.

Gotchas and Edge Cases#

not-last: targets the DOM's last child, not the one that looks last. If your final item is hidden, or you reorder with order-* or flex-row-reverse, :not(:last-child) still matches on document order — so the visually-last element keeps its border and a hidden trailing element silently holds the "last" slot. This is the same footgun that bites divide-* and space-* in v4, both of which use :not(:last-child) under the hood. If your list has conditionally hidden rows, negation on structure is the wrong tool; filter the collection server-side so the DOM order matches what actually renders.

Two smaller things. First, not-* is Tailwind v4 only — on v3 it will not compile, and the v3-to-v4 upgrade path is the fix. Second, do not reach for not-* when a dedicated variant already says it more clearly. not-motion-reduce: technically works, but motion-safe: expresses the same intent and everyone reading it knows what it means. Same with not-first:/not-last: versus first:/last: — if you are adding the style to the exception rather than removing it, the plain variant is clearer.

Wrapping Up#

Reach for not-* when you catch yourself adding a style to everything just to strip it off one element — dividers, button groups, hover states that should back off while active. It compiles to :not(), composes with every other variant, and keeps the intent in the markup instead of a trailing last: override.

From here, the natural next step is the rest of v4's no-JavaScript toolkit: animating display: none with starting: and transition-discrete applies the same "do it in CSS, not Alpine" thinking to enter and exit transitions.

FAQ#

What does the not-* variant do in Tailwind CSS?

It styles an element only when a condition is not true. not-{variant} takes any other Tailwind variant and wraps it in the CSS :not() pseudo-class, so not-hover: applies when the element is not hovered and not-last: applies to every element that is not the last child. It is negation as a first-class utility instead of a base style plus an override.

How do I use :not() with Tailwind v4?

You rarely write :not() by hand. Prefix an existing variant with not- and Tailwind generates the :not(...) selector for you — not-first:pt-0 compiles to &:not(:first-child). For a one-off arbitrary selector you can still use not-[...], for example not-[.is-active]:opacity-50, and Tailwind wraps whatever you pass inside the negation.

Can I combine not-* with hover or focus?

Yes, and that is where it shines. Stack it with another state variant and read the chain left to right: hover:not-active:bg-zinc-700 means "on hover, but not while active." not-focus: and not-hover: also work on their own. Order matters — the variant that comes last in the chain is the one being negated.

Does not-* work with responsive and supports queries?

It does. not-supports-[display:grid]:block applies when the browser does not support grid, giving you a progressive-enhancement fallback. You can negate feature and media queries the same way — not-forced-colors: for High Contrast mode, plus breakpoint and motion queries — so not-* covers both selector-level and environment-level conditions.

When should I use not-* instead of first or last variants?

Use not-* when you are removing a style from the exception, and use first:/last: when you are adding a style to it. "Border on all but the last row" reads best as not-last:border-b. "Extra top padding on only the first item" reads best as first:pt-4. If you find yourself adding a utility and then overriding it with last:, that is the signal to switch to negation.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.