Every Livewire app I ship has the same tell: the DOM snaps. A wizard step changes, a panel appears, a filter swaps the table out, and the pixels teleport. The usual fixes are worse than the problem — an x-transition on an Alpine wrapper that fights the morph, a CSS animation that replays on every re-render, or a JavaScript animation library bolted onto a server-rendered app.
Livewire 4 shipped wire:transition, which hands the swap to the browser instead. Below is the whole directive: named transitions, real keyframes, the direction-aware wizard almost nobody writes about, and the places it will bite you.
Add wire:transition to an element that enters and leaves#
Put wire:transition on any element Livewire adds, removes or replaces during an update. With no argument Livewire sets view-transition-name: match-element on that element, which is enough for the browser to crossfade it in and out without a single line of CSS from you.
class ShowPost extends Component
{
public Post $post;
public $showComments = false;
}
<div>
<button wire:click="$toggle('showComments')">Toggle comments</button>
@if ($showComments)
<div wire:transition>
@foreach ($post->comments as $comment)
<div>{{ $comment->body }}</div>
@endforeach
</div>
@endif
</div>
One correction before you go further, because it is repeated all over the place: wire:transition has no modifiers. There is no .enter, no .leave, no .duration.500ms. The reference table in the Livewire docs lists exactly two expressions — nothing, or a name — and says so explicitly. If you came looking for Tailwind classes to hang on an enter state, you are thinking of x-transition. This is a different mechanism.
Name the transition so you can target it in CSS#
Pass a string to set the element's view-transition-name to that exact value, which is what makes targeted animation possible. match-element generates an internal name you cannot select, so anything beyond a crossfade needs a name you chose.
<div wire:transition="sidebar">...</div>
That is the whole API surface on the Blade side. Everything else happens in CSS and in your action methods.
Write keyframes against the view transition pseudo-elements#
The browser snapshots the outgoing and incoming states and exposes them as pseudo-elements, so you write ordinary @keyframes and attach them by name. There are three: ::view-transition-old(name) for the outgoing snapshot, ::view-transition-new(name) for the incoming one, and ::view-transition-group(name) as the container for both.
::view-transition-old(sidebar) {
animation: 300ms ease-out both slide-out;
}
::view-transition-new(sidebar) {
animation: 300ms ease-in both slide-in;
}
@keyframes slide-out {
to { transform: translateX(-100%); }
}
@keyframes slide-in {
from { transform: translateX(100%); }
}
This genuinely belongs in a stylesheet. Tailwind cannot express view-transition pseudo-elements as utilities, and contorting it into an arbitrary-variant soup makes the animation unreadable. Write plain CSS, or wrap it in a @utility if you want it reusable. If your animation is a pure enter/leave on a display: none element and no Livewire round trip is involved, Tailwind v4's starting: variant and transition-discrete do the same job with zero server involvement.
Because the browser works from snapshots, layout is where surprises live. A position: fixed header, an overflow: hidden ancestor that clips the snapshot, or a new stacking context between the element and the root can all produce an animation that looks nothing like the one you wrote. When a transition renders wrong, check the element's ancestors before you touch the keyframes.
Set a transition type for direction-aware wizard animation#
A crossfade is fine for a panel. A wizard needs direction: forward should slide left, back should slide right. Set a transition type in the action and Livewire tags the whole swap with it, which you then target in CSS.
class Wizard extends Component
{
public $step = 1;
public function goToStep($step)
{
// Compare before mutating $this->step, or the direction inverts.
$this->transition(type: $step > $this->step ? 'forward' : 'backward');
$this->step = $step;
}
}
<div>
<div wire:transition="content">
Step {{ $step }}
</div>
<button wire:click="goToStep({{ $step - 1 }})">Back</button>
<button wire:click="goToStep({{ $step + 1 }})">Next</button>
</div>
html:active-view-transition-type(forward) {
&::view-transition-old(content) {
animation: 300ms ease-out both slide-out-left;
}
&::view-transition-new(content) {
animation: 300ms ease-in both slide-in-right;
}
}
html:active-view-transition-type(backward) {
&::view-transition-old(content) {
animation: 300ms ease-out both slide-out-right;
}
&::view-transition-new(content) {
animation: 300ms ease-in both slide-in-left;
}
}
@keyframes slide-out-left {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out-right {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
@keyframes slide-in-left {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
Four keyframe blocks looks like a lot until you realise it is the entire animation layer for a wizard. Bolt this onto a multi-step wizard with per-step validation and the form logic stays exactly as it was — the animation is additive.
Use the Transition attribute for actions that always move one way#
$this->transition() earns its place when direction is computed at runtime. When a method only ever goes one way, declare it once with the #[Transition] attribute and keep the method body clean.
use Livewire\Attributes\Transition;
class Wizard extends Component
{
public $step = 1;
#[Transition(type: 'forward')]
public function next()
{
$this->step++;
}
#[Transition(type: 'backward')]
public function previous()
{
$this->step--;
}
}
Same CSS as above. I default to the attribute and only drop to the method when the direction depends on an argument.
Name nested elements that should animate independently#
This is the part that will confuse you, so learn it before it costs you an afternoon. When a typed transition is active, Livewire treats the swap as one orchestrated unit — an unnamed wire:transition inside it stays unnamed and rides along with its parent's snapshot instead of becoming its own group with a default fade.
<div wire:transition="slide">
<p>Step content</p>
{{-- Unnamed: rides along with the parent's "slide" during a typed swap --}}
<div wire:transition>
<button>Save</button>
</div>
</div>
Give the inner element an explicit name and it breaks out into its own group with its own animation:
<div wire:transition="badge">...</div>
Outside a typed transition — an ordinary morph, like a validation error appearing — unnamed elements go back to match-element and animate independently as before. The behaviour only changes inside a typed swap. The same reasoning applies when you lazy-load components as islands: decide whether the island should move with its parent or on its own, then name it accordingly.
Skip the transition on reset and cancel actions#
Some actions should snap. A "start over" button that slowly slides back through four steps feels broken, not polished. Disable the transition for that request and the DOM updates instantly.
use Livewire\Attributes\Transition;
#[Transition(skip: true)]
public function resetWizard()
{
$this->step = 1;
}
Or imperatively, when the decision depends on state:
public function resetWizard()
{
$this->skipTransition();
$this->step = 1;
}
Keep view transition names unique inside loops#
view-transition-name must be unique among all rendered elements taking part in a transition. If two visible elements share a name, the ViewTransition.ready promise rejects and the browser skips the transition entirely — no animation, no console error, just a silent snap that looks like the feature is broken.
@foreach ($items as $item)
{{-- Unique per record: never collides --}}
<div wire:transition="card-{{ $item->id }}">
{{ $item->title }}
</div>
@endforeach
A bare wire:transition is also safe here, and is what I reach for first — match-element asks the browser to assign a unique internal name per element, which is precisely the collision you were trying to avoid. Only name items in a loop when you need to target one of them specifically in CSS.
Verify the reduced-motion and unsupported-browser paths#
Livewire respects prefers-reduced-motion for you: when the user has it enabled, transitions are disabled. There is nothing to write and nothing to polyfill, but do actually toggle the OS setting once and confirm the UI still makes sense when everything snaps.
Support is worth being precise about. View Transitions land in Chrome 111+, Edge 111+ and Safari 18+; unsupported browsers show and hide elements with no animation and no errors. Firefox 144+ supports basic view transitions but not transition types, so a typed wizard degrades to an untyped swap there — acceptable, but not what you tested. The match-element value behind the bare directive is newer still, needing roughly Chrome 137+, Safari 18.2+ and Firefox 144+, so an unnamed wire:transition is the first thing to lose on older browsers. If a swap is slow enough that the missing animation is noticeable, that is a loading-state problem, and skeleton loaders with @placeholder and islands fix it better than any transition can.
Know where wire:transition stops and Alpine begins#
wire:transition animates server-driven DOM swaps. x-transition animates client-side state. That is the whole boundary, and it settles most arguments about which to use.
wire:transition |
x-transition |
|
|---|---|---|
| Trigger | A Livewire round trip swapping the DOM | Alpine state changing in the browser |
| Mechanism | Native View Transitions API | JS-driven class timeline |
| Animation lives in | CSS pseudo-elements | Utility classes on the element |
So hover and focus micro-interactions, a dropdown that never talks to the server, and anything needing JS-timed choreography all stay with Alpine — as does modal state wired through wire:entangle, where Alpine owns the open/closed flag.
One more boundary: this is not a page-transition feature. wire:transition applies to component swaps, and full-page wire:navigate visits go through a different mechanism that, as of Livewire 4, still does not wrap its DOM swap in document.startViewTransition(). Don't expect a named transition to survive a navigate. Once your animations are in, lock the behaviour down with component tests in Pest — the transition itself is untestable in PHP, but the state changes that drive it are not.
FAQ#
How do I animate Livewire component updates?
Add wire:transition to the element that changes during the update. Livewire hands the DOM swap to the browser's native View Transitions API and crossfades the element by default. For anything more elaborate, give the transition a name and write @keyframes against the ::view-transition-old() and ::view-transition-new() pseudo-elements.
What is the difference between wire:transition and Alpine's x-transition?
They animate different things. wire:transition animates server-driven DOM swaps caused by a Livewire round trip, using the native View Transitions API with the animation defined in CSS. x-transition animates client-side Alpine state changes by applying classes across a JavaScript timeline. Use Alpine for interactions that never reach the server, and wire:transition for anything Livewire re-renders.
How do I animate a Livewire wizard forwards and backwards differently?
Use transition types. Call $this->transition(type: $step > $this->step ? 'forward' : 'backward') before mutating the step, or annotate fixed-direction methods with #[Transition(type: 'forward')] and #[Transition(type: 'backward')]. Then target html:active-view-transition-type(forward) and html:active-view-transition-type(backward) in CSS with the appropriate slide keyframes for each direction.
How do I disable a Livewire transition for one action?
Call $this->skipTransition() inside the action to disable transitions for that request, or add the #[Transition(skip: true)] attribute to the method. The canonical case is a reset or cancel button that should jump straight to its target state rather than animating back through several steps.
Which browsers support wire:transition?
View Transitions are supported in Chrome 111+, Edge 111+ and Safari 18+. Firefox 144+ supports basic view transitions but does not support transition types, so a direction-aware wizard degrades to an untyped swap. Browsers without support simply show and hide elements without animating — the functionality still works, so there is nothing to polyfill.
Does wire:transition respect prefers-reduced-motion?
Yes, automatically. Livewire disables transitions when the user has prefers-reduced-motion enabled, so you do not need to write a media query or guard the directive yourself. It is still worth toggling the setting once during development to confirm the interface reads correctly without any motion.