Tailwind v4 starting: and transition-discrete — Animate display:none Without JavaScript

Tailwind v4's starting: variant pairs with transition-discrete to fade elements in and out of display:none using only CSS. Cookbook with copy-paste examples.

Steven Richardson
Steven Richardson
· 9 min read

Every modal I have ever written has the same ugly compromise. CSS cannot transition display: none to display: block, so you either reach for Alpine's x-show with six lines of x-transition directives or you keep the element rendered and toggle opacity-0 pointer-events-none — which screen readers still announce. Tailwind v4 finally lets you stop. The starting: variant and transition-discrete utility wrap two new CSS primitives — @starting-style and transition-behavior: allow-discrete — into something that fits on one line of HTML.

If you are still on Tailwind v3, none of this works yet. The Tailwind v3 to v4 Laravel upgrade walkthrough is the path to get there. Everything below assumes v4.2 or later.

What @starting-style does at the CSS level#

The CSS spec used to be brutal about display. Browsers refused to animate properties on an element that was being inserted into the box tree, because there was no "previous frame" to interpolate from. You could declare opacity: 0 in your stylesheet and opacity: 1 on the element, and the browser would still snap it to 1 instantly when it appeared.

@starting-style is the missing previous frame. It declares the state the browser should pretend the element was in for the single tick before it became visible. The transition engine then has two values to interpolate between, and the entry animation runs.

.dialog {
    opacity: 1;
    transition: opacity 200ms ease-out;
}

@starting-style {
    .dialog {
        opacity: 0;
    }
}

That is the entry side. The exit side needs the second primitive: transition-behavior: allow-discrete. Properties like display and visibility are discrete — they have no in-between values. Without allow-discrete, the browser flips display to none on the very first frame of the exit animation and the fade is invisible. With it, the flip is deferred until the timeline finishes.

.dialog {
    transition: opacity 200ms ease-out, display 200ms allow-discrete;
}

Two CSS additions, and you have full enter and exit animations for a display: none element.

The starting: variant in Tailwind v4#

Tailwind v4 wraps @starting-style in a variant called starting:. Anything you prefix with starting: becomes part of the starting-style block for that element. The mental model is "this is the state I want to interpolate from when this element first appears."

<div class="opacity-100 starting:opacity-0 transition-opacity duration-200">
    Fades in on first paint.
</div>

The variant chains with the same modifiers you already use. The most useful combination is starting:open:, which targets the starting state of an element that just became :popover-open or matched [open]. That is what drives the entire native popover and dialog enter animation pattern. If you already use other CSS-first variants — for example the @custom-variant trick that powers Tailwind v4 dark modestarting: slots into the same stylesheet without any plugin or config.

transition-discrete and the allow-discrete behavior#

The variant alone is half the story. You also need transition-discrete so the browser will animate properties that change in a single step. It maps to transition-behavior: allow-discrete exactly.

transition-normal     → transition-behavior: normal;
transition-discrete   → transition-behavior: allow-discrete;

Add it on the same element as your transition-* utility group. Without it, display, visibility, overlay, and content-visibility will all snap to their final value on frame one. With it, they are held at their old value until the rest of the timeline finishes — which is what makes the exit fade visible.

<div class="hidden opacity-0 open:block open:opacity-100 starting:open:opacity-0
            transition-all transition-discrete duration-200">
    ...
</div>

Read that left to right: defaults to hidden and opacity-0. When the element matches [open] it flips to block and opacity-100. The starting:open:opacity-0 declaration is the "first frame" state during that flip, so the element fades in instead of popping. transition-discrete keeps display: block alive long enough for the reverse fade to play on close.

A modal that fades in and out with zero JS#

The clearest payoff is the native <dialog> element. No Alpine, no Livewire, no JS-driven aria-hidden toggling. The browser owns the open/close state, [open] is the styling hook, and Tailwind handles the rest.

<button onclick="document.getElementById('confirm-modal').showModal()"
        class="rounded-md bg-zinc-900 px-4 py-2 text-white">
    Delete user
</button>

<dialog id="confirm-modal"
        class="m-auto rounded-2xl bg-white p-6 shadow-2xl backdrop:bg-black/40
               opacity-0 -translate-y-2
               open:opacity-100 open:translate-y-0
               starting:open:opacity-0 starting:open:-translate-y-2
               transition-[opacity,transform,display,overlay]
               transition-discrete duration-200 ease-out">
    <h2 class="text-lg font-semibold">Delete user</h2>
    <p class="mt-2 text-zinc-600">This cannot be undone.</p>
    <form method="dialog" class="mt-6 flex justify-end gap-2">
        <button value="cancel" class="rounded-md px-3 py-1.5">Cancel</button>
        <button value="confirm" class="rounded-md bg-red-600 px-3 py-1.5 text-white">Confirm</button>
    </form>
</dialog>

A few things worth pointing out. transition-[opacity,transform,display,overlay] is an arbitrary value — you have to list display and overlay explicitly because the default transition-all group does not include them. overlay is the property the browser uses to lift the dialog into the top layer, and animating it gives you the backdrop fade. <form method="dialog"> closes the dialog when a button is clicked without writing any JavaScript.

If you have built modals before with Livewire state tied to Alpine, this replaces a chunk of the boilerplate. The $wire and @entangle pattern for modal state still earns its place when the modal carries server data, but for confirm-and-go-away dialogs the native <dialog> and starting: combo wins on weight.

A dropdown with enter and exit transitions#

The popover API uses the same machinery and the same open: variant — Tailwind treats [open] and :popover-open identically.

<button popovertarget="profile-menu" type="button"
        class="rounded-md bg-zinc-100 px-3 py-1.5">
    Profile
</button>

<div id="profile-menu" popover
     class="mt-2 w-56 rounded-lg bg-white p-1 shadow-lg ring-1 ring-zinc-200
            opacity-0 -translate-y-1
            open:opacity-100 open:translate-y-0
            starting:open:opacity-0 starting:open:-translate-y-1
            transition-[opacity,transform,display,overlay]
            transition-discrete duration-150 ease-out">
    <a href="/settings" class="block rounded px-3 py-1.5 hover:bg-zinc-100">Settings</a>
    <a href="/logout"  class="block rounded px-3 py-1.5 hover:bg-zinc-100">Sign out</a>
</div>

popovertarget wires the trigger to the popover. The browser handles outside-click-to-dismiss, the escape key, and focus management. You write zero JS for a menu that used to need Alpine plus a click-outside library. Compose this with other v4 utilities — bg-radial, the container query approach for responsive components — and a polished dropdown is a couple of dozen classes total.

When to still reach for Alpine#

starting: is for visibility changes that the DOM owns. The native popover or dialog is opening; the [open] attribute is flipping. If your state lives in an x-data object or a Livewire property, you cannot use :popover-open because the popover is not what is open — your bespoke component is.

For those, Alpine's x-show with x-transition is still the cleanest answer, and $wire plus @entangle covers the Livewire half. The CSS pattern in this article is not a replacement for Alpine; it is a replacement for the visibility transitions part of Alpine. Use it when the browser already exposes the open state. Stay with Alpine when the open state is yours.

There is one halfway option: toggle a class with Alpine and let the starting: mechanism do the animation. But you have to opt every property into the transition manually, and you lose the focus management you got for free from popover. I find that path is rarely worth it.

Gotchas and edge cases#

A handful of papercuts catch every team the first time.

transition-all does not include display, visibility, or overlay. Those have to be named in an arbitrary transition-[...] value or with transition-[display]. The blog post examples that "just work" usually only animate opacity, which is in the default all group.

The starting: variant only fires the first time the element renders into a new state. If your popover opens, closes, then opens again, the starting state fires again on each open — good. But if you keep the popover mounted and only toggle [open], you still get the fade. Where it bites is when you render an element into the page that is already open: the starting state applies on first paint, so the element fades in even though semantically nothing "opened." Decide whether you want that and use appearance: (or skip starting:) accordingly.

Browser support is solid in 2026 but not universal. Chrome and Edge 117+, Safari 17.5+, and Firefox 129+ all support the at-rule and the discrete behaviour. Older browsers skip the animation entirely and the element appears or disappears instantly. That is a clean fallback — nothing breaks, you just lose the fade — but check your analytics before you assume everyone gets the polished version.

The <dialog> element's ::backdrop pseudo-element needs its own transition declaration. Tailwind exposes it via the backdrop: variant, so something like backdrop:opacity-0 open:backdrop:opacity-100 starting:open:backdrop:opacity-0 backdrop:transition-opacity is what you want if the backdrop should fade with the dialog.

Finally, the v3-era transition shorthand is still here, but it does not opt display in. Always pair the visibility utility with transition-discrete — even if you have a transition-opacity already declared.

Wrapping Up#

The boring conclusion: try this on one modal next week. Convert a single x-show.transition dropdown to the popover plus starting: pattern and watch the JavaScript drop from your bundle. The cognitive load drops with it — fewer directives to memorise, fewer state machines for the same UX.

If you are still polishing the broader v4 stylesheet, the OKLCH and color-mix() approach to design tokens pairs naturally with this work, because both lean on CSS-first variables instead of config files.

FAQ#

What is the starting: variant in Tailwind v4?

starting: is a variant that maps to the CSS @starting-style at-rule. Whatever utility you prefix with starting: becomes the "first frame" state the browser interpolates from when the element appears. It is the missing half of CSS that lets you write enter animations for elements that move from display: none into the layout.

How do I animate display:none to display:block with Tailwind?

Combine three things on the same element: a hidden or opacity-0 default state, an open: (or other state) variant that sets the visible state, and transition-discrete plus an explicit transition-[opacity,display] so the display property participates in the timeline. Add starting:open:opacity-0 if you want a fade-in on first paint instead of a snap.

What does transition-discrete do in Tailwind v4?

transition-discrete compiles to transition-behavior: allow-discrete. It tells the browser to defer changes to discrete properties — display, visibility, content-visibility, overlay — until the rest of the transition timeline finishes. Without it, the element flips to display: none on frame one of the exit and the fade is invisible.

When should I use @starting-style instead of Alpine.js x-show transitions?

Use @starting-style (and the starting: variant) when the visibility state is owned by the DOM — a native <dialog>, a popover attribute, a [hidden] toggle from server-rendered HTML. Stay with Alpine's x-show plus x-transition when the state lives in x-data or a Livewire property, because there is no native pseudo-class the CSS can hook into.

Do CSS @starting-style and transition-behavior work in all browsers in 2026?

They work in Chrome and Edge 117+, Safari 17.5+, and Firefox 129+ — the Baseline 2024 set. As of May 2026 that covers the vast majority of users. Browsers that predate those versions skip the animation entirely and show or hide the element instantly. The fallback is graceful: the UI still functions, the user just does not get the fade.

How do I add an exit animation to an element that gets removed from the DOM?

You cannot animate an element that has already been removed from the DOM — nothing is left to transition. The trick is to keep the element mounted and let CSS handle the visibility flip. Use display: none plus transition-discrete, or toggle the popover and [open] attributes, so the browser owns the lifecycle and the exit fade has something to run on.

Steven Richardson
Steven Richardson

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