Alpine.js Transitions: Animations That Are Actually Smooth
AI generated
x-data
Alpine
Alpine.js · Transitions · CSS Animations · Performance
Alpine.js Transitions:
Animations That Are Actually Smooth

Jerky animations, content that pops in instantly on open or vanishes abruptly on close: these are the symptoms of a poorly built transition system. Alpine.js ships with x-transition, a well thought out framework for fluid enter and leave animations that delivers professional results with very little code.

16 min read x-transition · Enter · Leave · CSS · Coordination Alpine.js 3.x · Tailwind CSS v4 · prefers-reduced-motion

1. How x-transition works: enter and leave phases

The x-transition directive in Alpine.js is built on a three-phase system for each direction. When showing an element (enter), there is the x-transition:enter phase for styles applied throughout the whole enter transition, x-transition:enter-start for the starting state and x-transition:enter-end for the end state. The same pattern applies when hiding an element (leave): x-transition:leave, x-transition:leave-start and x-transition:leave-end. Alpine.js manages the classes automatically: enter-start is set, then after a tick it switches to enter-end, and that triggers the CSS transition.

This system is deliberately built so that CSS drives the actual animation. Alpine.js only toggles the classes, the browser runs the transition. That means x-transition relies on the browser's GPU-accelerated CSS transition mechanism: no JavaScript animation loop, no requestAnimationFrame overhead for the actual motion. Alpine.js only manages the lifecycle: when the element is inserted into the DOM, when classes get set, when it is removed again.

A common misunderstanding: x-transition only works together with x-show or x-if. It is not a standalone trigger, it is a modifier that reacts to a visibility change. x-show tends to be the more common choice for transitions, because x-if removes the element from the DOM entirely and re-inserts it on the next enter phase, which can cause a brief layout recalculation for complex transitions.

2. x-transition modifiers: duration, delay, opacity, scale

For simple cases Alpine.js offers handy inline modifiers right on the x-transition directive. With x-transition.duration.300ms the transition duration is set to 300ms. With x-transition.opacity only the opacity is animated. With x-transition.scale.90 the element starts at 90% size. These modifiers can be combined: x-transition.duration.200ms.opacity.scale.95 produces a fade-in shrink animation in 200ms.

The modifiers are handy for simple use cases but come with clear limits. They do not support different enter and leave durations, no delayed starts (transition-delay) and no complex easing functions beyond the browser defaults. As soon as an animation gets more complex, different easing curves, sequences, or specific transform properties, switching to custom CSS classes is the right move.

// Simple modifier variant for quick dropdown transitions
// In the template: 
// Advanced variant: separate enter/leave phases with custom classes // In the template: //
// Alpine.js component for an animated dropdown function animatedDropdown() { return { open: false, selectedLabel: 'Select...', options: [ { value: 'de', label: 'Germany' }, { value: 'at', label: 'Austria' }, { value: 'ch', label: 'Switzerland' } ], toggle() { this.open = !this.open; }, select(option) { this.selectedLabel = option.label; this.open = false; this.$dispatch('vendor:option-selected', { value: option.value }); }, close() { this.open = false; } }; }

3. Custom CSS classes instead of inline modifiers

For professional animations, custom CSS classes across the six x-transition phases are the right choice. The system allows maximum control: different transition-timing-function values for enter (ease-out for a natural fade-in) and leave (ease-in for a natural fade-out), specific transform properties, delays for staggered animations, and the use of any custom CSS animations you like. In Hyva with Tailwind CSS v4, every transition utility is available out of the box.

The important pattern for custom CSS transitions: the transition property belongs in the x-transition:enter and x-transition:leave classes, that is, in the base classes, not in the start/end classes. enter-start and enter-end only define the CSS values the browser interpolates between. A common mistake is setting the transition property in enter-start, which causes the browser to read it too late so no animation happens at all.

// CSS for a smooth sidebar animation (Tailwind CSS v4 or custom CSS)
// .sidebar-enter: transition-property: transform, opacity; transition-duration: 350ms; transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
// .sidebar-enter-start: transform: translateX(-100%); opacity: 0;
// .sidebar-enter-end: transform: translateX(0); opacity: 1;
// .sidebar-leave: transition-property: transform, opacity; transition-duration: 250ms; transition-timing-function: cubic-bezier(0.7, 0, 0.84, 0);
// .sidebar-leave-start: transform: translateX(0); opacity: 1;
// .sidebar-leave-end: transform: translateX(-100%); opacity: 0;

function mobileSidebar() {
  return {
    open: false,

    init() {
      // ESC key closes the sidebar
      this.$el.addEventListener('keydown', (e) => {
        if (e.key === 'Escape' && this.open) this.close();
      });

      // Lock body scroll while the sidebar is open
      this.$watch('open', (isOpen) => {
        document.body.style.overflow = isOpen ? 'hidden' : '';
      });
    },

    toggle() { this.open = !this.open; },

    close() {
      this.open = false;
      // Return focus to the trigger element
      this.$el.querySelector('[data-sidebar-trigger]')?.focus();
    }
  };
}

4. Coordinated animations and sequences

Coordinated animations happen when several elements animate one after another or in parallel, such as an overlay backdrop that fades in first, followed by a modal dialog that slides in with a slight delay. In Alpine.js you solve this with transition-delay in the CSS classes of the individual elements, while the shared toggle state lives at a higher level. Both elements react to the same x-show condition but carry different delay values.

For more elaborate sequences, such as a list of items fading in one after another, you use JavaScript-based delays combined with Alpine.js state. Each item gets an index, from which a transition-delay is calculated: item 0 delays 0ms, item 1 delays 50ms, item 2 delays 100ms. That creates a stagger effect that looks far more polished than fading in every item at once.

// Coordinated modal animation: backdrop + dialog with delay
function coordinatedModal() {
  return {
    open: false,
    // Backdrop and dialog react to the same state
    // In the template: backdrop with delay 0, dialog with delay 75ms

    show() {
      this.open = true;
      // Move focus into the modal after the transition
      this.$nextTick(() => {
        this.$el.querySelector('[data-modal-first-focus]')?.focus();
      });
    },

    hide() {
      this.open = false;
    }
  };
}

// Stagger animation for a product card list
function productGrid() {
  return {
    visible: false,
    items: [],

    init() {
      // Mark items with a staggered delay
      this.$nextTick(() => {
        const cards = this.$el.querySelectorAll('[data-product-card]');
        cards.forEach((card, i) => {
          card.style.transitionDelay = `${i * 60}ms`;
        });
        // Toggle visible after a short delay (prevents FOUC)
        requestAnimationFrame(() => { this.visible = true; });
      });
    }
  };
}

A correct modal pattern with Alpine.js transitions consists of three parts: a backdrop element, the dialog element, and a scroll lock on the body. All three need to be animated in a coordinated way. The backdrop fades in and out with a simple opacity transition. The dialog combines opacity with a vertical translate animation (slightly down on enter, back on leave), which matches a natural sense of gravity. The body scroll lock is set and removed in sync with the state toggle.

What is missing from many implementations is focus management. On open, focus needs to move into the modal, on close, it needs to return to the triggering element. Alpine.js $nextTick makes sure the DOM change has completed before focus is set. Without proper focus handling, a modal is unusable for keyboard users and screen readers, and in Hyva projects built for a professional context that is a hard requirement, not a nice to have.

6. List transitions: animating items in and out

Animating list items in Alpine.js works conceptually differently from frameworks like Vue that ship dedicated transition-group components. In Alpine.js you put x-show together with x-transition on each individual list item and control visibility through the item's state. For dynamically added items, such as in a filtered product list, you combine x-for with x-transition directly on the loop element.

One critical detail for list transitions: x-for and x-transition work together, but the transition applies to the entire loop element. When items are removed, the leave transition runs before the element is removed from the DOM. That is correct behavior, but it means the container still reserves space for the disappearing items while the leave transition runs. If you do not account for that, you will see layout jumps when removing filtered items.

// Animated filtered list with Alpine.js
function filteredList() {
  return {
    search: '',
    allItems: [
      { id: 1, name: 'Alpine.js Introduction', tag: 'tutorial' },
      { id: 2, name: 'Tailwind CSS Grid', tag: 'css' },
      { id: 3, name: 'Hyva Performance', tag: 'performance' },
      { id: 4, name: 'x-transition Guide', tag: 'tutorial' },
      { id: 5, name: 'Custom Directives', tag: 'advanced' }
    ],

    get filtered() {
      if (!this.search) return this.allItems;
      const q = this.search.toLowerCase();
      return this.allItems.filter(item =>
        item.name.toLowerCase().includes(q) ||
        item.tag.toLowerCase().includes(q)
      );
    },

    // Key for x-for: avoids unnecessary DOM recycling
    // In the template: