Dynamic Transitions in Alpine.js

/ Content

Alpine's x-transition directive handles 90% of animation needs with almost no effort. Slap it on an element controlled by x-show and you get a smooth fade and scale effect. The docs cover customization well: .duration, .scale, .opacity, and the full CSS class approach with x-transition:enter and friends.

Where it falls apart is when you need animations that change. Not different animations for different elements, but the same element with different animations depending on what the user just did. A carousel is the classic example. Click "Next" and the content should slide in from the right. Click "Previous" and it should slide in from the left. Same element, different animation, determined at runtime.

The Setup

Here's a basic carousel without animations:

<div x-data="{ 
    current: 0, 
    slides: ['Slide 1', 'Slide 2', 'Slide 3'],
    next() { this.current = (this.current + 1) % this.slides.length },
    previous() { this.current = (this.current - 1 + this.slides.length) % this.slides.length }
}">
    <template x-for="(slide, index) in slides" :key="index">
        <div x-show="current === index" x-text="slide"></div>
    </template>
    
    <button @click="previous">Previous</button>
    <button @click="next">Next</button>
</div>

Works fine. No animation. Slides just appear and disappear.

Adding Static Transitions

Adding x-transition gives us fade animations:

<div x-show="current === index" x-transition x-text="slide"></div>

Better, but the animation is the same regardless of direction. What we want:

  • Next: new slide enters from right, old slide exits to left
  • Previous: new slide enters from left, old slide exits to right

The Obvious Approach (That Doesn't Work)

You might think to use x-bind with the CSS class approach:

<div 
    x-data="{ 
        current: 0,
        direction: 'right',
        slides: ['Slide 1', 'Slide 2', 'Slide 3'],
        next() { 
            this.direction = 'right';
            this.current = (this.current + 1) % this.slides.length;
        },
        previous() { 
            this.direction = 'left';
            this.current = (this.current - 1 + this.slides.length) % this.slides.length;
        }
    }"
>
    <template x-for="(slide, index) in slides" :key="index">
        <div 
            x-show="current === index"
            x-transition:enter="transition duration-300"
            x-transition:enter-start.class="direction === 'right' ? 'translate-x-full' : '-translate-x-full'"
            x-transition:enter-end="translate-x-0"
            x-transition:leave="transition duration-300"
            x-transition:leave-start="translate-x-0"
            x-transition:leave-end.class="direction === 'right' ? '-translate-x-full' : 'translate-x-full'"
            x-text="slide"
        ></div>
    </template>
    
    <button @click="previous">Previous</button>
    <button @click="next">Next</button>
</div>

That's not valid syntax, but even if you try variations with x-bind, it won't work. The slides always animate the same direction regardless of which button you click.

Why It Doesn't Work

To understand why, we need to look at what x-transition actually does. The directive runs when the component mounts and reads the transition configuration once. It doesn't set up watchers to detect changes in the bound values.

Here's a simplified version of what happens in the Alpine source:

directive('transition', (el, { value, modifiers, expression }, { evaluate }) => {
    if (typeof expression === 'function') expression = evaluate(expression)
    
    if (!expression || typeof expression === 'boolean') {
        registerTransitionsFromHelper(el, modifiers, value)
    } else {
        registerTransitionsFromClassString(el, expression, value)
    }
})

The key insight is in registerTransitionObject:

function registerTransitionObject(el, setFunction, defaultValue = {}) {
    if (!el._x_transition) el._x_transition = {
        enter: { during: defaultValue, start: defaultValue, end: defaultValue },
        leave: { during: defaultValue, start: defaultValue, end: defaultValue },
        
        in(before = () => {}, after = () => {}) {
            transition(el, setFunction, {
                during: this.enter.during,
                start: this.enter.start,
                end: this.enter.end,
            }, before, after)
        },
        
        out(before = () => {}, after = () => {}) {
            transition(el, setFunction, {
                during: this.leave.during,
                start: this.leave.start,
                end: this.leave.end,
            }, before, after)
        },
    }
}

Alpine creates a _x_transition property on the DOM element containing the transition configuration. This happens once when the directive initializes. There's no $watch, no effect(), no reactivity. The configuration is set and then used as-is whenever the element shows or hides.

The Solution

Since _x_transition is just a property on the DOM element, we can modify it ourselves before triggering a transition. We just need to update the configuration right before changing visibility.

<div 
    x-data="{ 
        current: 0,
        slides: ['Slide 1', 'Slide 2', 'Slide 3'],
        
        next() { 
            this.setDirection('right');
            this.current = (this.current + 1) % this.slides.length;
        },
        
        previous() { 
            this.setDirection('left');
            this.current = (this.current - 1 + this.slides.length) % this.slides.length;
        },
        
        setDirection(dir) {
            const slideEls = this.$root.querySelectorAll('[data-slide]');
            
            slideEls.forEach(el => {
                if (!el._x_transition) return;
                
                if (dir === 'right') {
                    el._x_transition.enter.start = 'opacity-0 translate-x-full';
                    el._x_transition.enter.end = 'opacity-100 translate-x-0';
                    el._x_transition.leave.start = 'opacity-100 translate-x-0';
                    el._x_transition.leave.end = 'opacity-0 -translate-x-full';
                } else {
                    el._x_transition.enter.start = 'opacity-0 -translate-x-full';
                    el._x_transition.enter.end = 'opacity-100 translate-x-0';
                    el._x_transition.leave.start = 'opacity-100 translate-x-0';
                    el._x_transition.leave.end = 'opacity-0 translate-x-full';
                }
            });
        }
    }"
>
    <div class="relative overflow-hidden">
        <template x-for="(slide, index) in slides" :key="index">
            <div 
                data-slide
                x-show="current === index"
                x-transition:enter="transition duration-300 ease-out"
                x-transition:enter-start="opacity-0"
                x-transition:enter-end="opacity-100"
                x-transition:leave="transition duration-300 ease-in absolute inset-0"
                x-transition:leave-start="opacity-100"
                x-transition:leave-end="opacity-0"
                x-text="slide"
                class="p-8 bg-white rounded shadow"
            ></div>
        </template>
    </div>
    
    <div class="mt-4 space-x-2">
        <button @click="previous" class="px-4 py-2 bg-gray-200 rounded">Previous</button>
        <button @click="next" class="px-4 py-2 bg-gray-200 rounded">Next</button>
    </div>
</div>

The x-transition directives still need to be present to initialize the _x_transition object. The initial values don't matter much since we overwrite them before each transition. The data-slide attribute is just for selecting the elements.

How It Works

  1. User clicks "Next"
  2. next() calls setDirection('right')
  3. setDirection finds all slide elements and modifies their _x_transition properties
  4. this.current changes, triggering x-show changes
  5. Alpine reads the (now modified) _x_transition config and applies the correct animation

The order matters. You must update _x_transition before changing the value that triggers the show/hide. If you do it the other way around, the transition has already started with the old configuration.

Cleaner Implementation

For production, I'd extract this into a reusable component:

// resources/js/carousel.js
export default (slides = []) => ({
    current: 0,
    slides,
    
    init() {
        // Set initial transition state after Alpine processes directives
        this.$nextTick(() => this.setDirection('right'));
    },
    
    next() {
        this.setDirection('right');
        this.current = (this.current + 1) % this.slides.length;
    },
    
    previous() {
        this.setDirection('left');
        this.current = (this.current - 1 + this.slides.length) % this.slides.length;
    },
    
    goTo(index) {
        this.setDirection(index > this.current ? 'right' : 'left');
        this.current = index;
    },
    
    setDirection(dir) {
        const enterFrom = dir === 'right' ? 'translate-x-full' : '-translate-x-full';
        const leaveTo = dir === 'right' ? '-translate-x-full' : 'translate-x-full';
        
        this.$root.querySelectorAll('[data-carousel-slide]').forEach(el => {
            if (!el._x_transition) return;
            
            el._x_transition.enter.start = `opacity-0 ${enterFrom}`;
            el._x_transition.enter.end = 'opacity-100 translate-x-0';
            el._x_transition.leave.start = 'opacity-100 translate-x-0';
            el._x_transition.leave.end = `opacity-0 ${leaveTo}`;
        });
    }
});

Register it with Alpine:

import Alpine from 'alpinejs';
import carousel from './carousel';

Alpine.data('carousel', carousel);
Alpine.start();

Use it:

<div x-data="carousel(['First', 'Second', 'Third'])">
    <div class="relative overflow-hidden h-48">
        <template x-for="(slide, index) in slides" :key="index">
            <div 
                data-carousel-slide
                x-show="current === index"
                x-transition:enter="transition duration-300 ease-out"
                x-transition:enter-start=""
                x-transition:enter-end=""
                x-transition:leave="transition duration-300 ease-in absolute inset-0"
                x-transition:leave-start=""
                x-transition:leave-end=""
                x-text="slide"
                class="h-full flex items-center justify-center bg-blue-100 rounded"
            ></div>
        </template>
    </div>
    
    <div class="mt-4 flex justify-between">
        <button @click="previous">Previous</button>
        <div class="space-x-2">
            <template x-for="(_, index) in slides">
                <button 
                    @click="goTo(index)"
                    :class="current === index ? 'bg-blue-500' : 'bg-gray-300'"
                    class="w-3 h-3 rounded-full"
                ></button>
            </template>
        </div>
        <button @click="next">Next</button>
    </div>
</div>

The empty x-transition:enter-start="" attributes might look weird, but they're needed to ensure the _x_transition object is created with the right structure. The actual values get overwritten by setDirection.

Should You Do This?

Manipulating internal Alpine properties like _x_transition isn't officially supported. The Alpine Discord will tell you it's not recommended. But _x_transition is specifically designed to be read when transitions fire, it's not some complex reactive state. It's a configuration object that Alpine checks at transition time.

The alternative is managing all of this with CSS and additional wrapper elements, which gets complicated fast. Or using a full animation library like GSAP or Motion, which adds weight for a simple carousel.

For most dynamic transition needs, this approach is clean and works reliably. Just document what you're doing so future you (or your team) understands the pattern.

If Alpine ever adds reactive transition support natively, this technique becomes unnecessary. Until then, it's a reasonable solution to a real problem.