Skip to content
Alpine.js Transitions & Plugins — Complete Guide

Alpine.js Transitions & Plugins — Complete Guide

DodaTech Updated Jun 6, 2026 14 min read

Alpine.js transitions and plugins extend the framework from simple reactive components into a full-featured UI toolkit — letting you animate elements smoothly, persist data across page loads, trap focus in modals, format input masks, and even create your own custom directives.

What You’ll Learn

By the end of this tutorial, you’ll control Alpine’s transition system for custom animations, use all the magic properties effectively, integrate official plugins (persist, focus, collapse, mask, intersect, morph, sort), and write your own reusable custom directives.

Before starting: Complete the Getting Started, Bindings & Events, and Conditionals & Loops tutorials first.

Why Transitions & Plugins Matter

A website without animations feels jarring. Elements appear and disappear instantly. Modals snap open without context. Users get confused about what changed and where to look.

Transitions solve this by guiding the user’s eye — a smooth fade tells the brain “something appeared here.” A slide says “new content arrived from above.” These micro-interactions make software feel polished and professional.

Plugins solve common problems without reinventing the wheel. Need to save data across page refreshes? There’s a plugin for that. Need to trap focus inside a modal for accessibility? There’s a plugin. Need to format a phone number as the user types? Plugin.

Real-world use: Durga Antivirus Pro uses Alpine’s persist plugin to remember user preferences (scan type, theme, notification settings) across sessions. The focus plugin ensures their settings dialog is keyboard-accessible. Transitions make scan results feel responsive and alive.

Where This Fits in Your Learning Path

    flowchart LR
    A["Getting Started"] --> B["Bindings & Events"]
    B --> C["Conditionals & Loops"]
    C --> D["**Transitions & Plugins**"]
    D --> E["Real Alpine.js Apps"]
    style D fill:#f97316,stroke:#c2410c,color:#fff
    style A fill:#e5e7eb,stroke:#9ca3af,color:#374151
    style E fill:#22c55e,stroke:#16a34a,color:#fff
  

The x-transition System

Alpine’s transition system provides smooth CSS animations when elements enter or leave the DOM. It works with both x-show and x-if.

How Transitions Work

When you use x-transition, Alpine adds and removes CSS classes at specific moments during an element’s appearance or disappearance:

<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>
  <div x-show="open" x-transition>
    I fade and scale in/out
  </div>
</div>

With just x-transition (no customization), Alpine applies sensible defaults: 150ms ease-out for entering, 75ms ease-in for leaving, with opacity and transform transitions.

What happens step-by-step when the element appears:

  1. Alpine adds the element to the DOM (or makes it visible)
  2. It applies the start classes (e.g., opacity: 0; transform: scale(0.95))
  3. On the next frame, it removes start classes and adds end classes (e.g., opacity: 1; transform: scale(1))
  4. CSS transitions handle the animation between the two states
  5. After the transition duration, cleanup classes are removed

Custom Transition Directives

For full control, Alpine provides six transition directives:

<div x-show="open"
     x-transition:enter="transition ease-out duration-300"
     x-transition:enter-start="opacity-0 scale-90"
     x-transition:enter-end="opacity-100 scale-100"
     x-transition:leave="transition ease-in duration-200"
     x-transition:leave-start="opacity-100 scale-100"
     x-transition:leave-end="opacity-0 scale-90">
DirectiveWhen it appliesWhat to put
x-transition:enterThe entire enter phaseBase transition properties like duration and easing
x-transition:enter-startThe moment enter beginsThe starting state (opacity 0, scaled down)
x-transition:enter-endAfter enter-start, the final stateThe ending state (opacity 1, normal size)
x-transition:leaveThe entire leave phaseLeave transition properties (often faster)
x-transition:leave-startThe moment leave beginsStarting state of leave (same as normal)
x-transition:leave-endAfter leave-start, the final stateEnding state of leave (opacity 0, scaled down)

Transition Modifiers

Alpine also provides shorthand modifiers for common effects:

<!-- Duration only: 500ms enter/leave -->
<div x-show="open" x-transition.duration.500ms">

<!-- Opacity only (no scale/transform) -->
<div x-show="open" x-transition.opacity">

<!-- Scale only (no opacity) -->
<div x-show="open" x-transition.scale">

<!-- Opacity and scale with custom origin -->
<div x-show="open" x-transition.scale.80.opacity.origin.top.right">

<!-- With delay -->
<div x-show="open" x-transition.delay.200ms">

<!-- Custom origin for scale transforms -->
<div x-show="open" x-transition.origin.top.right">

Why choose opacity-only or scale-only? Performance. The browser can composite opacity changes on the GPU without triggering layout recalculations. If you have many elements animating simultaneously, opacity-only transitions are smoother.

Transitions with x-if

When used with x-if, Alpine intelligently waits for the leave animation to complete before removing the element from the DOM:

<template x-if="show">
  <div x-transition:enter="transition ease-out duration-300"
       x-transition:enter-start="opacity-0"
       x-transition:enter-end="opacity-100"
       x-transition:leave="transition ease-in duration-200"
       x-transition:leave-start="opacity-100"
       x-transition:leave-end="opacity-0">
    Content that animates out before DOM removal
  </div>
</template>

Without this behavior, x-if would snap the element out instantly. With transitions, Alpine measures the CSS transition duration from the leave classes and waits that long before removing the element.

Transition Groups (Animated Lists)

Animate items as they’re added or removed from a list:

<div x-data="{ items: [1, 2, 3] }">
  <button @click="items = [...items, items.length + 1]">Add</button>
  <template x-for="item in items" :key="item">
    <div x-transition:enter="transition ease-out duration-300"
         x-transition:enter-start="opacity-0 transform -translate-x-4"
         x-transition:enter-end="opacity-100 transform translate-x-0"
         x-transition:leave="transition ease-in duration-200"
         x-transition:leave-start="opacity-100 transform translate-x-0"
         x-transition:leave-end="opacity-0 transform translate-x-4">
      <span x-text="item"></span>
    </div>
  </template>
</div>

New items slide in from the left. Removed items slide out to the left. The rest of the list flows naturally to fill the gap.


Magic Properties Deep Dive

You’ve seen $store, $refs, and $watch already. Let’s master them and the others.

$store — Global Reactive State

The store is a shared data container accessible from any component:

<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('theme', {
      mode: 'light',
      primary: '#3b82f6',
      toggle() {
        this.mode = this.mode === 'light' ? 'dark' : 'light'
      }
    })
  })
</script>

<div x-data>
  <p>Current theme: <span x-text="$store.theme.mode"></span></p>
  <button @click="$store.theme.toggle()">Toggle Theme</button>
</div>

When to use $store: For truly global state — theme, user authentication, notification counts, shopping cart item count. If only one component needs the data, keep it in that component’s x-data.

$nextTick — After DOM Update

Execute code after Alpine has finished updating the DOM:

<div x-data="{ show: false, height: 0 }">
  <button @click="
    show = true;
    $nextTick(() => { height = $refs.content.offsetHeight })
  ">
    Show & Measure
  </button>
  <div x-show="show" x-ref="content">
    <p>This is the measured content.</p>
  </div>
  <p x-text="`Height: ${height}px`"></p>
</div>

Why $nextTick is necessary: After you set show = true, Alpine hasn’t updated the DOM yet. The content is still hidden. $nextTick waits for Alpine to finish rendering, then runs your callback. At that point, $refs.content exists and is visible, so offsetHeight returns the correct value.

$dispatch — Custom Event Communication

Alpine components communicate through custom DOM events:

<div x-data @custom-event="console.log($event.detail)">
  <div x-data>
    <button @click="$dispatch('custom-event', { text: 'Hello!' })">
      Dispatch
    </button>
  </div>
</div>

Events bubble up naturally. A child component dispatches, the parent (or any ancestor) catches it with @event-name.

Magic Properties Quick Reference

PropertyTypeWhat it does
$storeRead/WriteAccess a globally registered store
$nextTickFunctionRun code after DOM update
$watchFunctionObserve a property for changes
$dispatchFunctionFire a custom event up the DOM tree
$elRead-onlyThe root DOM element of this component
$refsRead-onlyElements with x-ref attributes
$eventRead-onlyCurrent browser event object
$dataRead-onlyComponent’s reactive data object
$rootRead-onlyRoot element of the closest Alpine component

Alpine Official Plugins

Alpine’s plugin system extends its capabilities without bloating the core. Install only what you need.

@alpinejs/persist — Data That Survives Refreshes

Save state to localStorage automatically:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ count: Alpine.$persist(0) }">
  <button @click="count++">Count: <span x-text="count"></span></button>
  <p>Refresh the page — the count persists!</p>
</div>

Alpine.$persist(0) returns a reactive proxy that syncs with localStorage. The default value is 0. Every change is automatically saved. On page load, the saved value is restored.

Custom key: Alpine.$persist(0).as('my-custom-key') — useful when you have multiple persisted values and want to avoid naming conflicts.

@alpinejs/focus — Accessible Focus Trapping

Essential for modals and dialogs. Traps keyboard focus within an element so Tab doesn’t escape behind the overlay:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ open: false }">
  <button @click="open = true">Open Modal</button>
  <div x-show="open"
       x-trap.noscroll="open"
       @click.outside="open = false"
       @keydown.escape="open = false">
    <h2>Modal</h2>
    <input type="text" placeholder="Tab stays inside here">
    <button @click="open = false">Close</button>
  </div>
</div>

x-trap.noscroll="open" — When open is true, Alpine traps focus inside this element. The .noscroll modifier also prevents the background page from scrolling. When open becomes false, focus trapping is released.

Why this matters for accessibility: Users who navigate by keyboard (Tab, Shift+Tab) should never be able to tab “behind” a modal overlay. Focus trapping keeps them inside the modal until they explicitly close it. This is required by WCAG accessibility guidelines.

@alpinejs/collapse — Smooth Expand/Collapse

Animates height from 0 to auto and back — something CSS alone cannot do smoothly:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ expanded: false }">
  <button @click="expanded = !expanded">Toggle</button>
  <div x-show="expanded" x-collapse.duration.300ms>
    <p>This content will smoothly expand and collapse.</p>
    <p>Height animates from 0 to auto and back.</p>
  </div>
</div>

The challenge x-collapse solves: CSS transition: height 0.3s doesn’t work with height: auto. You’d need a fixed pixel height, which is impractical for dynamic content. x-collapse measures the actual height and animates to/from it programmatically.

@alpinejs/mask — Input Formatting

Format values as the user types — phone numbers, prices, dates:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>

<div x-data>
  <input x-mask="(999) 999-9999" placeholder="Phone number">
  <input x-mask="$99.99" placeholder="Price">
  <input x-mask="99/99/9999" placeholder="Date">
</div>

The 9 character represents a digit placeholder. As the user types, Alpine automatically inserts the formatting characters (parentheses, dashes, dollar sign, dots, slashes).

@alpinejs/intersect — Viewport Detection

Trigger actions when an element scrolls into view:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ visible: false }">
  <div style="height: 100vh;"><!-- spacer --></div>
  <div x-intersect="visible = true">
    <p x-show="visible" x-transition>
      I appear when you scroll down to me!
    </p>
  </div>
</div>

Use cases:

  • Lazy-loading images or content
  • Triggering animations on scroll
  • Tracking which sections the user has viewed (analytics)
  • Implementing infinite scroll

@alpinejs/morph — Efficient DOM Transitions

Morph one DOM tree into another, preserving focus, scroll position, and input states:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ tab: 'profile' }">
  <button @click="tab = 'profile'">Profile</button>
  <button @click="tab = 'settings'">Settings</button>

  <div x-morph:x-data="tabData">
    <template x-if="tab === 'profile'">
      <div><h2>Profile</h2><input type="text" placeholder="Name"></div>
    </template>
    <template x-if="tab === 'settings'">
      <div><h2>Settings</h2><input type="text" placeholder="API Key"></div>
    </template>
  </div>
</div>

Why morph instead of replace: If you’re typing in an input and switch tabs, a regular Alpine swap would destroy your input state. Morph preserves it. The DOM is smart-diffed — only changed elements are replaced.

@alpinejs/sort — Drag-and-Drop Lists

Make lists sortable via drag-and-drop:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/sort@3.x.x/dist/cdn.min.js"></script>

<div x-data="{ items: ['Apple', 'Banana', 'Cherry', 'Date'] }">
  <ul x-sort="items">
    <template x-for="item in items" :key="item">
      <li x-sort:item x-text="item"
          style="padding: 0.5rem; cursor: grab; border: 1px solid #ccc; margin: 0.25rem 0;">
      </li>
    </template>
  </ul>
</div>

x-sort="items" on the container enables drag-and-drop reordering. x-sort:item on each list item makes it draggable. When the user drags and drops, the items array is automatically reordered.


Custom Directives with Alpine.directive()

When the built-in directives and plugins aren’t enough, you can create your own reusable directives.

Creating a Tooltip Directive

document.addEventListener('alpine:init', () => {
  Alpine.directive('tooltip', (el, { expression }, { evaluate, cleanup }) => {
    // Get the tooltip text from the expression
    const text = evaluate(expression)

    // Show tooltip on hover
    const show = () => {
      const tip = document.createElement('div')
      tip.textContent = text
      tip.style.cssText = 'position: absolute; background: #333; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px; z-index: 1000;'
      el.style.position = 'relative'
      el.appendChild(tip)
      el._tip = tip
    }

    // Hide tooltip
    const hide = () => {
      el._tip?.remove()
      el._tip = null
    }

    // Attach event listeners
    el.addEventListener('mouseenter', show)
    el.addEventListener('mouseleave', hide)

    // Cleanup when directive is removed
    cleanup(() => {
      el.removeEventListener('mouseenter', show)
      el.removeEventListener('mouseleave', hide)
      hide()
    })
  })
})

Usage in HTML:

<button x-tooltip="'Save your changes'">Save</button>

What each part does:

  • el — The DOM element the directive is attached to.
  • { expression } — The value inside the directive ('Save your changes').
  • { evaluate } — Safely evaluates the expression as JavaScript.
  • cleanup — A function you call with a callback that runs when the directive is destroyed (component removed or element re-rendered). Essential for preventing memory leaks.

Custom Directive Lifecycle

LifecycleWhen it runs
initWhen the directive is first bound to an element
destroyWhen the directive is torn down (component removed)
updateWhen the component’s data changes and re-renders

Common Mistakes Beginners Make

1. Forgetting to register plugins before Alpine.start()

<!-- Wrong: Alpine already started -->
<script src="alpinejs"></script>
<script>
  Alpine.plugin(persist)
  Alpine.start()  // Already started by the CDN script!
</script>

<!-- Correct (CDN): register via alpine:init -->
<script>
  document.addEventListener('alpine:init', () => {
    Alpine.plugin(persist)
  })
</script>

<!-- Correct (npm): register before start -->
<script>
  Alpine.plugin(persist)
  Alpine.start()
</script>

When using CDN, Alpine starts automatically. Register plugins inside the alpine:init event listener instead.

2. Using x-transition without defining CSS transition properties

<!-- This won't animate -->
<div x-show="open" x-transition:enter="my-enter">

<!-- Need CSS for the animation -->
<style>
  .my-enter {
    transition: opacity 0.3s ease;
    opacity: 0;
  }
</style>

The CSS classes Alpine adds must have corresponding transition properties defined in your CSS. Without transition: opacity 0.3s, the opacity change is instant, not animated.

3. Not cleaning up event listeners in custom directives

<!-- Memory leak: event listeners survive component destruction -->
Alpine.directive('bad', (el) => {
  window.addEventListener('resize', handler)
})

<!-- Correct: cleanup prevents memory leaks -->
Alpine.directive('good', (el, {}, { cleanup }) => {
  window.addEventListener('resize', handler)
  cleanup(() => window.removeEventListener('resize', handler))
})

If you add global event listeners (window, document) without cleanup, they accumulate when components are destroyed and recreated. This causes memory leaks and performance degradation over time.

4. Using $store without registering it first

<!-- Wrong: store doesn't exist -->
<div x-data>
  <p x-text="$store.user.name"></p>
</div>

<!-- Correct: register the store first -->
<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('user', { name: 'Alice' })
  })
</script>

Alpine throws an error if you access an unregistered store.

5. Applying transitions to too many elements simultaneously

Animating 100+ items at once can cause jank (stuttering). Keep simultaneous animated elements under 50-100 items depending on the device. Use will-change: transform, opacity on animated elements for GPU acceleration.


Practice Questions

  1. What’s the difference between x-transition:enter-start and x-transition:enter-end? enter-start is the initial state (applied when the element first appears), enter-end is the final state. CSS transitions animate from start to end.

  2. How does @alpinejs/persist save data? It uses localStorage under the hood. Alpine.$persist(0) creates a reactive value that automatically syncs with localStorage.

  3. Why is x-trap.noscroll important for accessibility? It traps keyboard focus inside a modal (so Tab doesn’t go behind it) and prevents background page scrolling — both required by WCAG accessibility standards.

  4. What does the cleanup callback in custom directives do? It lets you register a function that runs when the directive is destroyed, allowing you to remove event listeners and prevent memory leaks.

  5. Can you use x-transition with x-if? Yes. Alpine waits for the leave animation to complete before removing the element from the DOM, unlike x-show where the element stays.

Challenge

Build a “notification toast system” that:

  • Uses Alpine.store() to manage a list of notifications
  • Each notification has a type (success, error, info) and a message
  • Displays notifications using x-for with x-transition for slide-in/out animation
  • Auto-removes notifications after 3 seconds
  • Has buttons to trigger different types of notifications

Try It Yourself

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Alpine.js Animated Demo</title>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
  <style>
    body { font-family: system-ui; padding: 2rem; display: flex; justify-content: center; }
    .card { background: white; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); padding: 2rem; width: 100%; max-width: 400px; }
    .btn { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 1rem; }
    .btn-primary { background: #3b82f6; color: white; }
    .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; }
    .modal-content { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; }
  </style>
</head>
<body>
  <div x-data="{ open: false }" class="card">
    <h2>Animated Modal Demo</h2>
    <button class="btn btn-primary" @click="open = true">Open Modal</button>

    <div class="modal-overlay"
         x-show="open"
         x-transition:enter="transition ease-out duration-200"
         x-transition:enter-start="opacity-0"
         x-transition:enter-end="opacity-100"
         x-transition:leave="transition ease-in duration-150"
         x-transition:leave-start="opacity-100"
         x-transition:leave-end="opacity-0"
         @click.outside="open = false"
         @keydown.escape="open = false">
      <div class="modal-content"
           x-transition:enter="transition ease-out duration-200"
           x-transition:enter-start="opacity-0 scale-95"
           x-transition:enter-end="opacity-100 scale-100"
           x-transition:leave="transition ease-in duration-150"
           x-transition:leave-start="opacity-100 scale-100"
           x-transition:leave-end="opacity-0 scale-95">
        <h3 style="margin-top: 0;">Animated Modal</h3>
        <p>This modal fades the overlay and scales the content.</p>
        <button class="btn btn-primary" @click="open = false">Close</button>
      </div>
    </div>
  </div>
</body>
</html>

What to expect: A button that opens a modal with a two-part animation — the dark overlay fades in, and the modal content scales up. Closing reverses the animation: the content scales down, then the overlay fades out.


FAQ

{< faq >}

What is Alpinejs Transitions Plugins?
Alpinejs Transitions Plugins refers to the core concepts and practices used to build and manage modern web applications. Understanding it is essential for web developers.
Do I need prior experience to learn Alpinejs Transitions Plugins?
Basic familiarity with web development concepts helps, but Alpinejs Transitions Plugins can be learned step by step even as a beginner.
How long does it take to learn Alpinejs Transitions Plugins?
With consistent practice, you can grasp the fundamentals in a few days to a week. Mastery takes ongoing practice and real-world projects.
Where can I use Alpinejs Transitions Plugins in real projects?
Alpinejs Transitions Plugins is used in a wide range of applications — from simple websites to complex enterprise systems, depending on the specific tools and technologies involved.
What are common tools used with Alpinejs Transitions Plugins?
The specific tools depend on the technology stack, but version control (Git), package managers, and testing frameworks are commonly used alongside most development topics.

{< /faq >}

What’s Next

You’ve mastered Alpine.js! To explore similar lightweight JavaScript tools:

TutorialWhat You’ll Learn
StimulusAnother lightweight JS framework
HTMXAJAX-driven HTML without JavaScript

Related topics: JavaScript fundamentals, CSS animations, Web accessibility (WCAG).

What’s Next

Congratulations on completing this Alpinejs Transitions Plugins tutorial! Here’s where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro