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.
Table of Contents
- 1. How x-transition works: enter and leave phases
- 2. x-transition modifiers: duration, delay, opacity, scale
- 3. Custom CSS classes instead of inline modifiers
- 4. Coordinated animations and sequences
- 5. Modals and overlays with correct transitions
- 6. List transitions: animating items in and out
- 7. Performance: what actually makes it smooth
- 8. Accessibility: respecting prefers-reduced-motion
- 9. Transitions compared side by side
- 10. Summary
- 11. FAQ
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; });
});
}
};
}
5. Modals and overlays with correct transitions
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:
//
};
}
7. Performance: what actually makes it smooth
Smooth, in browser terms, means 60fps with no layout thrashing. The basic rule for performant transitions: only animate opacity and transform. These two properties are handled by the browser at the GPU compositor level without triggering a layout recalculation or a paint step. Everything else, width, height, top, left, margin, padding, font-size, triggers an expensive layout reflow when animated and causes stutter.
A common performance issue in Alpine.js transitions is animating height: 0 to height: auto. That does not work directly in CSS and is often worked around with JavaScript calculations that then actually cause layout thrashing. The correct pattern: use max-height with a sufficiently large maximum value, or better still, Alpine.js's Hyva-compatible x-collapse plugin, which implements this animation efficiently using ResizeObserver.
8. Accessibility: respecting prefers-reduced-motion
The media feature prefers-reduced-motion: reduce signals that a user prefers less motion, often for medical reasons such as vestibular disorders or epilepsy. Any Alpine.js animation that does not react to this signal is an accessibility violation. Tailwind CSS v4 provides the utility class motion-reduce:transition-none, which disables transitions for these users. In custom CSS you use the media query directly.
The correct accessibility pattern for Alpine.js transitions: the enter/leave classes always include motion-reduce:transition-none motion-reduce:transform-none. That way users without a motion preference see the full animation, while users with prefers-reduced-motion see instant state changes with no transition. Removing the animation does not mean the content is worse, it just appears immediately instead of with a delay.
Animation type
Anti-pattern
Recommended pattern
Reason
Fade in
display: none → block
x-show + x-transition:opacity
GPU-accelerated, smooth
Height animation
height: 0 → auto
x-collapse plugin
No layout thrashing
Position animation
animating top/left
transform: translateX/Y
No reflow, GPU layer
Reduced motion
No regard for the preference
motion-reduce:transition-none
Accessibility, medical reasons
Stagger lists
All items at once
transition-delay: index * 60ms
Professional staggering
Mironsoft
Alpine.js, Hyva Themes and performant Magento frontends
Want smooth animations for your Hyva project?
We implement Alpine.js transitions that are actually smooth: GPU-optimized, accessibility compliant and coordinated for complex UI sequences in Hyva themes.
UI components
Modals, dropdowns, sidebars and accordions with professional transitions
Performance audit
Analyzing jerky animations and switching them to GPU-accelerated transitions
Accessibility
prefers-reduced-motion and WCAG-compliant animation implementations
10. Summary
Alpine.js x-transition is a well thought out system for enter and leave animations that uses CSS as the engine and only relies on Alpine.js for lifecycle management. The three-phase architecture (enter, enter-start, enter-end and the equivalent for leave) enables precise control over every transition. Smooth means: only animate opacity and transform, choose transition-timing-function deliberately (ease-out for enter, ease-in for leave), and respect prefers-reduced-motion.
For coordinated animations you use transition-delay in CSS, for stagger effects you use JavaScript-computed delays based on index. The x-collapse plugin solves the height-to-auto problem without layout thrashing. In Hyva projects with Tailwind CSS v4, every utility you need is already available, the real work lies in correctly structuring the six transition phases and knowing which CSS properties can be animated with GPU acceleration.
Alpine.js Transitions: the essentials at a glance
6 transition phases
enter, enter-start, enter-end plus leave, leave-start, leave-end. transition property in enter/leave, CSS values in start/end.
Performance rule
Only animate opacity and transform. Everything else triggers a layout reflow. No height: 0 to auto, use x-collapse instead.
Easing convention
Enter: ease-out (naturally arriving). Leave: ease-in (naturally departing). Different durations for enter and leave are normal.
Accessibility
motion-reduce:transition-none in every enter/leave class. Respecting prefers-reduced-motion is mandatory, not optional.
11. FAQ: Alpine.js Transitions and Animations
1Why does my x-transition not work?
x-transition needs x-show or x-if. The transition property must sit in enter/leave, not in enter-start. Without the transition property the browser animates nothing.
2Inline modifiers versus custom classes?
Modifiers for simple cases. Custom classes for different durations, easing and delays. As soon as things get complex, switch to custom classes.
3Animating height: auto?
Use the x-collapse plugin. Direct CSS height: 0 to auto does not work. JavaScript calculations lead to layout thrashing.
4Why only opacity and transform?
The GPU compositor processes them without a layout reflow. Every other property triggers an expensive recalculation and causes stutter.
5Stagger effect for lists?
Compute transition-delay by index: card.style.transitionDelay = `${i * 60}ms`. Staggers the fade-in without separate transition instances.
6What is prefers-reduced-motion?
A browser signal that the user prefers less motion. motion-reduce:transition-none in Tailwind disables transitions automatically. Mandatory, not optional.
7Building coordinated animations?
Multiple elements on the same x-show state, different transition-delay values in CSS. Backdrop first, dialog slightly delayed.
8Combining x-transition with x-for?
Yes, directly on the template element inside the x-for loop. Leave runs before DOM removal. Container must reserve space for disappearing items.
9Ease-out for enter, ease-in for leave?
Ease-out (fast start, slow end) feels like a natural arrival. Ease-in (slow start, fast end) feels like a natural departure. A convention from animation design.
10transition in enter or enter-start?
In x-transition:enter. The browser reads the transition property before it applies enter-start. Set in enter-start, it is too late and no animation happens.