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-variant — not-* 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.