Aller au contenu

Alpine.js

Alpine.js est un framework JavaScript minimaliste (~15kb gzippé) qui apporte la réactivité de Vue.js directement dans votre HTML. C’est l’outil parfait pour ajouter de l’interactivité sans la complexité d’un framework full-stack.

FrameworkTaillePhilosophieCas d’usage
Alpine.js15 KBDéclaratif dans HTMLInteractivité légère, composants
jQuery87 KBImpératif, manipulation DOMLegacy, événements simples
React44 KB +Component-based, Virtual DOMSPA, apps complexes
Vue.js34 KB +Component-based, réactifSPA, apps moyennes-complexes
Vanilla JS0 KBNatif navigateurMicro-interactions

Avantages spécifiques :

  • Taille minimale : 15 KB vs 87 KB jQuery (déjà inclus dans Drupal)
  • Pas de build : Fonctionne directement en CDN ou via Vite
  • Syntaxe déclarative : x-data, x-show, x-on dans le HTML
  • Réactivité automatique : Les données se synchronisent automatiquement
  • Parfait avec Twig : Pas de conflit avec les templates Drupal
  • Composants réutilisables : via Alpine.data() et Alpine.store()
  • Compatible Tailwind : Même philosophie utility-first

Cas d’usage dans TailStore :

  • 🛒 Panier e-commerce avec LocalStorage
  • 🎨 Filtres produits dynamiques
  • 📱 Menu mobile avec drawer
  • 🖼️ Galeries images interactives
  • 🔔 Notifications toast
  • ✅ Formulaires avec validation
┌─────────────────────────────────────────┐
│ Templates Twig (.html.twig) │
│ Directives Alpine (x-data, x-on) │
└──────────────┬──────────────────────────┘
┌─────────────────────────────────────────┐
│ Alpine.js (assets/main.js) │
│ - Alpine.store (état global) │
│ - Alpine.data (composants) │
└──────────────┬──────────────────────────┘
┌─────────────────────────────────────────┐
│ LocalStorage / SessionStorage │
│ (persistance données) │
└─────────────────────────────────────────┘

Approche jQuery (ancienne) :

// Impératif, verbeux
(function ($, Drupal) {
Drupal.behaviors.myFeature = {
attach: function (context) {
$('.btn', context).once('myFeature').on('click', function () {
var count = parseInt($('.count').text());
count++;
$('.count').text(count);
});
}
};
})(jQuery, Drupal);

Approche Alpine.js (moderne) :

<!-- Déclaratif, concis -->
<div x-data="{ count: 0 }">
<button @click="count++" class="btn">Increment</button>
<span x-text="count" class="count"></span>
</div>
# tailstore.libraries.yml
alpine:
version: 3.x
header: true
js:
https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js:
type: external
minified: true
attributes:
defer: true
global:
version: 1.0
css:
theme:
dist/assets/main.css: { minified: true }
dependencies:
- tailstore/alpine
Fenêtre de terminal
cd themes/custom/tailstore
npm install alpinejs

Créez assets/main.js :

// assets/main.js
import './style.css'
import Alpine from 'alpinejs';
// Plugins optionnels
// import collapse from '@alpinejs/collapse';
// import focus from '@alpinejs/focus';
// Alpine.plugin(collapse);
// Alpine.plugin(focus);
// Rendre Alpine accessible globalement
window.Alpine = Alpine;
// Démarrer Alpine
Alpine.start();

Vite gère automatiquement le bundling :

{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}

Déclarez dans libraries.yml :

global:
version: 1.0
css:
theme:
dist/assets/main.css: { minified: true }
js:
dist/assets/main.js: { minified: true }

Objectif : Comprendre x-data, x-text, et @click.

  1. Créez un template de test test-alpine.html.twig :

    <div class="p-8">
    <div x-data="{ count: 0 }" class="text-center">
    <h2 class="text-2xl font-bold mb-4">Compteur Alpine.js</h2>
    <p class="text-4xl mb-4" x-text="count"></p>
    <div class="flex gap-2 justify-center">
    <button @click="count--" class="btn btn-secondary">-</button>
    <button @click="count = 0" class="btn btn-outline">Reset</button>
    <button @click="count++" class="btn btn-primary">+</button>
    </div>
    </div>
    </div>
  2. Vérifiez qu’Alpine.js est chargé (F12 → Console) :

    typeof Alpine !== 'undefined' // Doit retourner true
  3. Testez le compteur : les clics doivent mettre à jour le nombre

Validation : ✅ Le compteur s’incrémente/décrémente au clic


Objectif : Maîtriser x-show et les transitions.

  1. Créez un composant accordéon :

    <div x-data="{ open: false }" class="border rounded-lg p-4">
    <button
    @click="open = !open"
    class="flex items-center justify-between w-full font-semibold"
    >
    <span>Détails du produit</span>
    <svg
    class="w-5 h-5 transition-transform"
    :class="{ 'rotate-180': open }"
    fill="none"
    stroke="currentColor"
    viewBox="0 0 24 24"
    >
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
    </svg>
    </button>
    <div
    x-show="open"
    x-transition:enter="transition ease-out duration-200"
    x-transition:enter-start="opacity-0 -translate-y-2"
    x-transition:enter-end="opacity-100 translate-y-0"
    x-transition:leave="transition ease-in duration-150"
    x-transition:leave-start="opacity-100 translate-y-0"
    x-transition:leave-end="opacity-0 -translate-y-2"
    class="mt-4 text-gray-600"
    >
    <p>Contenu qui apparaît/disparaît avec animation.</p>
    </div>
    </div>
  2. Testez l’accordéon : le contenu doit apparaître avec animation

Validation : ✅ Le contenu apparaît/disparaît avec transition fluide


Objectif : Afficher une liste dynamique avec x-for.

  1. Créez une liste de tâches simple :

    <div
    x-data="{
    newTask: '',
    tasks: ['Créer le thème', 'Ajouter Tailwind', 'Intégrer Alpine']
    }"
    class="p-8 max-w-md mx-auto"
    >
    <h2 class="text-2xl font-bold mb-4">Todo List</h2>
    <form @submit.prevent="tasks.push(newTask); newTask = ''" class="mb-4">
    <div class="flex gap-2">
    <input
    x-model="newTask"
    type="text"
    placeholder="Nouvelle tâche..."
    class="form-input flex-1"
    >
    <button type="submit" class="btn btn-primary">Ajouter</button>
    </div>
    </form>
    <ul class="space-y-2">
    <template x-for="(task, index) in tasks" :key="index">
    <li class="flex items-center justify-between p-3 bg-gray-50 rounded">
    <span x-text="task"></span>
    <button
    @click="tasks.splice(index, 1)"
    class="text-red-500 hover:text-red-700"
    >
    </button>
    </li>
    </template>
    </ul>
    </div>
  2. Testez :

    • Ajoutez des tâches
    • Supprimez des tâches
    • Vérifiez que tout est réactif

Validation : ✅ Les tâches s’ajoutent/suppriment instantanément


Objectif : Utiliser Alpine.store() pour un état partagé.

  1. Dans assets/main.js, ajoutez un store :

    import Alpine from 'alpinejs';
    // Store panier
    Alpine.store('cart', {
    items: [],
    add(product) {
    this.items.push(product);
    },
    remove(index) {
    this.items.splice(index, 1);
    },
    get count() {
    return this.items.length;
    }
    });
    window.Alpine = Alpine;
    Alpine.start();
  2. Créez un bouton “Ajouter au panier” :

    <div x-data="{ product: { id: 1, name: 'T-Shirt', price: 29.99 } }">
    <button
    @click="$store.cart.add(product)"
    class="btn btn-primary"
    >
    Ajouter au panier
    </button>
    </div>
  3. Affichez le compteur du panier :

    <div x-data>
    <button class="relative btn btn-outline">
    🛒 Panier
    <span
    x-show="$store.cart.count > 0"
    x-text="$store.cart.count"
    class="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
    ></span>
    </button>
    </div>
  4. Testez : ajoutez des produits et vérifiez que le compteur se met à jour

Validation : ✅ Le compteur du panier se met à jour en temps réel

// assets/stores/cart.js
document.addEventListener('alpine:init', () => {
Alpine.store('cart', {
items: JSON.parse(localStorage.getItem('cart') || '[]'),
get count() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
},
get total() {
return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
},
add(product) {
const existing = this.items.find(i => i.id === product.id);
if (existing) {
existing.quantity++;
} else {
this.items.push({ ...product, quantity: 1 });
}
this.save();
this.notify(`${product.name} ajouté au panier`);
},
remove(productId) {
this.items = this.items.filter(i => i.id !== productId);
this.save();
},
updateQuantity(productId, quantity) {
const item = this.items.find(i => i.id === productId);
if (item) {
item.quantity = Math.max(1, quantity);
this.save();
}
},
clear() {
this.items = [];
this.save();
},
save() {
localStorage.setItem('cart', JSON.stringify(this.items));
},
notify(message) {
// Dispatch event pour les toasts
window.dispatchEvent(new CustomEvent('toast', {
detail: { message, type: 'success' }
}));
}
});
});
{# node--product--card.html.twig #}
<article
class="card group"
x-data="{
product: {
id: {{ node.id }},
name: '{{ label|escape('js') }}',
price: {{ node.field_price.value }},
image: '{{ file_url(node.field_images.0.entity.fileuri) }}'
}
}"
>
{# ... image et infos ... #}
<button
@click="$store.cart.add(product)"
class="btn btn-primary w-full"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
Ajouter au panier
</button>
</article>
{# block--mini-cart.html.twig #}
<div
x-data="{ open: false }"
class="relative"
>
{# Trigger #}
<button
@click="open = !open"
class="relative p-2 text-gray-600 hover:text-primary transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"/>
</svg>
{# Badge count #}
<span
x-show="$store.cart.count > 0"
x-text="$store.cart.count"
class="absolute -top-1 -right-1 w-5 h-5 bg-primary text-white text-xs rounded-full flex items-center justify-center"
></span>
</button>
{# Dropdown #}
<div
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-1"
@click.outside="open = false"
class="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-xl z-50"
>
<div class="p-4 border-b">
<h3 class="font-semibold">Votre panier</h3>
</div>
<template x-if="$store.cart.items.length === 0">
<div class="p-8 text-center text-gray-500">
Votre panier est vide
</div>
</template>
<template x-if="$store.cart.items.length > 0">
<div>
<ul class="divide-y max-h-64 overflow-y-auto">
<template x-for="item in $store.cart.items" :key="item.id">
<li class="p-4 flex gap-4">
<img :src="item.image" class="w-16 h-16 object-cover rounded">
<div class="flex-1 min-w-0">
<p x-text="item.name" class="font-medium truncate"></p>
<p class="text-sm text-gray-500">
<span x-text="item.quantity"></span> ×
<span x-text="item.price.toFixed(2) + ' €'"></span>
</p>
</div>
<button
@click="$store.cart.remove(item.id)"
class="text-gray-400 hover:text-red-500"
>
</button>
</li>
</template>
</ul>
<div class="p-4 border-t bg-gray-50">
<div class="flex justify-between font-semibold mb-4">
<span>Total</span>
<span x-text="$store.cart.total.toFixed(2) + ' €'"></span>
</div>
<a href="/checkout" class="btn btn-primary w-full">
Commander
</a>
</div>
</div>
</template>
</div>
</div>
{# block--mobile-menu.html.twig #}
<div
x-data="{ open: false }"
@toggle-mobile-menu.window="open = !open"
>
{# Overlay #}
<div
x-show="open"
x-transition:enter="transition-opacity ease-linear duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-linear duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
class="fixed inset-0 bg-black/50 z-40 md:hidden"
></div>
{# Drawer #}
<aside
x-show="open"
x-transition:enter="transform transition ease-in-out duration-300"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in-out duration-300"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="fixed top-0 left-0 bottom-0 w-72 bg-white z-50 overflow-y-auto md:hidden"
>
<div class="p-4 border-b flex justify-between items-center">
<span class="font-bold text-xl">Menu</span>
<button @click="open = false" class="p-2 hover:bg-gray-100 rounded">
</button>
</div>
<nav class="p-4">
{{ content }}
</nav>
</aside>
</div>
{# field--field-quantity.html.twig #}
<div
x-data="{ quantity: 1 }"
class="flex items-center border rounded-lg overflow-hidden w-fit"
>
<button
@click="quantity = Math.max(1, quantity - 1)"
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 transition-colors"
></button>
<input
type="number"
x-model.number="quantity"
min="1"
class="w-16 text-center border-0 focus:ring-0"
>
<button
@click="quantity++"
class="px-3 py-2 bg-gray-100 hover:bg-gray-200 transition-colors"
>+</button>
</div>
{# field--field-images--full.html.twig #}
<div
x-data="{
active: 0,
images: {{ images|json_encode|raw }}
}"
class="grid gap-4"
>
{# Main image #}
<div class="aspect-square overflow-hidden rounded-lg bg-gray-100">
<template x-for="(image, index) in images" :key="index">
<img
x-show="active === index"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
:src="image.url"
:alt="image.alt"
class="w-full h-full object-cover"
>
</template>
</div>
{# Thumbnails #}
<div class="grid grid-cols-5 gap-2">
<template x-for="(image, index) in images" :key="index">
<button
@click="active = index"
:class="{ 'ring-2 ring-primary': active === index }"
class="aspect-square overflow-hidden rounded-lg"
>
<img
:src="image.url"
:alt="image.alt"
class="w-full h-full object-cover"
>
</button>
</template>
</div>
</div>
{# block--toasts.html.twig #}
<div
x-data="{
toasts: [],
add(message, type = 'success') {
const id = Date.now();
this.toasts.push({ id, message, type });
setTimeout(() => this.remove(id), 5000);
},
remove(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
}
}"
@toast.window="add($event.detail.message, $event.detail.type)"
class="fixed bottom-4 right-4 z-50 space-y-2"
>
<template x-for="toast in toasts" :key="toast.id">
<div
x-show="true"
x-transition:enter="transform ease-out duration-300"
x-transition:enter-start="translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transform ease-in duration-200"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="translate-x-full opacity-0"
:class="{
'bg-green-500': toast.type === 'success',
'bg-red-500': toast.type === 'error',
'bg-blue-500': toast.type === 'info'
}"
class="px-4 py-3 rounded-lg text-white shadow-lg flex items-center gap-2"
>
<span x-text="toast.message"></span>
<button @click="remove(toast.id)" class="ml-2 hover:opacity-75"></button>
</div>
</template>
</div>

Alpine.js gère l’UI, htmx gère les requêtes AJAX :

<div
x-data="{ loading: false }"
@htmx:before-request="loading = true"
@htmx:after-request="loading = false"
>
<button
hx-post="/api/cart/add"
hx-vals='{"product_id": "{{ node.id }}"}'
hx-swap="none"
class="btn btn-primary"
:class="{ 'opacity-50 cursor-wait': loading }"
:disabled="loading"
>
<span x-show="!loading">Ajouter au panier</span>
<span x-show="loading">Chargement...</span>
</button>
</div>

Ajoutez dans votre code pour debug :

<div x-data="{ items: [] }">
{# Debug panel #}
<pre x-show="false" x-text="JSON.stringify($data, null, 2)"></pre>
</div>

Ou utilisez l’extension navigateur Alpine.js devtools.

1. Lazy loading des stores

// ❌ Mauvais - Charge tout au démarrage
Alpine.store('products', {
items: await fetchAllProducts() // Bloquant
});
// ✅ Bon - Charge à la demande
Alpine.store('products', {
items: [],
async load() {
if (this.items.length === 0) {
this.items = await fetchAllProducts();
}
}
});

2. Utiliser x-cloak pour éviter le flash

/* Dans votre CSS */
[x-cloak] {
display: none !important;
}
<!-- Évite le flash de contenu non-initialisé -->
<div x-data="{ loaded: false }" x-cloak>
<div x-show="loaded">
<!-- Contenu -->
</div>
</div>

3. Éviter les x-data trop volumineux

<!-- ❌ Mauvais - Trop de logique dans x-data -->
<div x-data="{
products: [],
filters: {},
sort: 'name',
async loadProducts() { /* 50 lignes */ },
filterProducts() { /* 30 lignes */ },
sortProducts() { /* 20 lignes */ }
}">
<!-- ✅ Bon - Extraire dans Alpine.data() -->
<div x-data="productList">
// assets/components/productList.js
Alpine.data('productList', () => ({
products: [],
filters: {},
sort: 'name',
async loadProducts() {
// Logique claire et testable
},
filterProducts() {
// ...
}
}));

1. Échapper les données Drupal

<!-- ❌ Dangereux - XSS possible -->
<div x-data="{ name: '{{ node.title }}' }">
<!-- ✅ Sécurisé - Échappement JavaScript -->
<div x-data="{ name: '{{ node.title|escape('js') }}' }">
<!-- ✅ Encore mieux - JSON encode -->
<div x-data="{ product: {{ product|json_encode|raw }} }">

2. Valider les inputs

Alpine.store('cart', {
add(product) {
// ✅ Validation
if (!product?.id || !product?.price) {
console.error('Invalid product');
return;
}
// ✅ Sanitize
this.items.push({
id: parseInt(product.id),
name: String(product.name).slice(0, 100),
price: parseFloat(product.price)
});
}
});

3. Limiter les actions sensibles

<!-- ❌ Permet suppression directe -->
<button @click="$store.cart.items = []">Vider</button>
<!-- ✅ Confirmation requise -->
<button @click="confirm('Vider le panier ?') && $store.cart.clear()">Vider</button>

1. Nommage des directives

<!-- Préfixer les handlers avec 'on' ou 'handle' -->
<button @click="handleAddToCart(product)">Ajouter</button>
<form @submit.prevent="onSubmitForm">...</form>
<!-- Nommer les toggles avec 'is' ou 'show' -->
<div x-data="{ isOpen: false, showModal: false }">

2. Organisation des fichiers

assets/
├── main.js # Point d'entrée
├── style.css # Styles globaux
├── stores/ # États globaux
│ ├── cart.js
│ ├── wishlist.js
│ └── filters.js
└── components/ # Composants Alpine.data()
├── productCard.js
├── productGallery.js
└── mobileMenu.js

3. Commenter les composants complexes

/**
* Product Gallery Component
*
* Gère l'affichage d'images produit avec thumbnails.
* Supporte clavier (←/→) et swipe mobile.
*
* @usage <div x-data="productGallery" x-init="init({{ images|json_encode }})">
*/
Alpine.data('productGallery', () => ({
active: 0,
images: [],
init(images) {
this.images = images;
this.setupKeyboard();
},
// ...
}));

1. Gérer le focus

Alpine.data('modal', () => ({
open: false,
show() {
this.open = true;
// Focus sur le premier élément
this.$nextTick(() => {
this.$refs.closeButton.focus();
});
},
hide() {
this.open = false;
// Retour au trigger
this.$refs.trigger.focus();
}
}));
<div x-data="modal">
<button @click="show" x-ref="trigger">Ouvrir</button>
<div x-show="open" @keydown.escape="hide">
<button @click="hide" x-ref="closeButton"></button>
</div>
</div>

2. Attributs ARIA

<!-- Menu déroulant accessible -->
<div x-data="{ open: false }">
<button
@click="open = !open"
:aria-expanded="open"
aria-controls="menu"
aria-haspopup="true"
>
Menu
</button>
<div
x-show="open"
id="menu"
role="menu"
@click.outside="open = false"
>
<a role="menuitem" href="/products">Produits</a>
</div>
</div>

3. Annoncer les changements

<div x-data="{ count: 0 }">
<button @click="count++; $dispatch('announce', `${count} produits`)">+</button>
<!-- Zone ARIA live -->
<div
@announce.window="$el.textContent = $event.detail"
aria-live="polite"
aria-atomic="true"
class="sr-only"
></div>
</div>

Symptôme : Console JavaScript affiche l’erreur

Causes :

  • Alpine.js non chargé
  • Script chargé après l’utilisation
  • Conflit de versions

Solution :

# tailstore.libraries.yml - Vérifier l'ordre
global:
js:
dist/assets/main.js: {} # Alpine doit être dans main.js
dependencies:
- core/drupal
// main.js - Vérifier l'initialisation
import Alpine from 'alpinejs';
window.Alpine = Alpine; // ✅ Exposer globalement
Alpine.start(); // ✅ Démarrer Alpine

Symptôme : Les directives Alpine sont ignorées

Cause : Alpine démarré avant le DOM

Solution :

// ❌ Mauvais
Alpine.start();
document.addEventListener('DOMContentLoaded', () => {
// Trop tard
});
// ✅ Bon
import Alpine from 'alpinejs';
// Enregistrer stores et components
Alpine.store('cart', { /* ... */ });
// Démarrer (Alpine attend automatiquement le DOM)
Alpine.start();

Symptôme : Items ajoutés mais pas affichés

Cause : Mutation d’array sans réactivité

Solution :

<!-- ❌ Mauvais - Mutation directe -->
<button @click="items[0] = newItem">Update</button>
<!-- ✅ Bon - Remplacement complet -->
<button @click="items = [...items.slice(0, -1), newItem]">Update</button>
<!-- ✅ Ou utiliser des méthodes réactives -->
<button @click="items.push(newItem)">Add</button>
<button @click="items.splice(index, 1)">Remove</button>

Symptôme : Modifications dans un composant n’apparaissent pas ailleurs

Cause : Store non enregistré globalement

Solution :

// ❌ Mauvais - Store local
Alpine.data('cart', () => ({
items: [] // Chaque instance a sa propre copie
}));
// ✅ Bon - Store global
Alpine.store('cart', {
items: [] // Partagé entre tous les composants
});

Symptôme : Panier vidé au rechargement de page

Cause : Pas de persistance

Solution :

// Option 1 : Plugin persist (recommandé)
import persist from '@alpinejs/persist';
Alpine.plugin(persist);
Alpine.store('cart', {
items: Alpine.$persist([]).as('cart_items')
});
// Option 2 : Manuel avec localStorage
Alpine.store('cart', {
items: JSON.parse(localStorage.getItem('cart') || '[]'),
add(item) {
this.items.push(item);
this.save();
},
save() {
localStorage.setItem('cart', JSON.stringify(this.items));
}
});

Symptôme : Animations ne sont pas fluides

Cause : Propriétés non optimisées pour GPU

Solution :

/* ❌ Mauvais - Pas optimisé GPU */
.dropdown {
transition: height 0.3s;
}
/* ✅ Bon - GPU accelerated */
.dropdown {
transition: transform 0.3s, opacity 0.3s;
will-change: transform, opacity;
}
<!-- Utiliser les transitions Alpine optimisées -->
<div
x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
>
Fenêtre de terminal
npm install @alpinejs/persist # Persistance localStorage
npm install @alpinejs/focus # Gestion focus
npm install @alpinejs/collapse # Animations collapse
npm install @alpinejs/intersect # Intersection Observer
  • Alpine.js installé (CDN ou npm)
  • Store panier implémenté
  • Mini-cart avec dropdown
  • Menu mobile avec drawer
  • Galerie produit interactive
  • Toasts notifications
  • Debug configuré

L’interactivité est en place ! Passons aux exercices pour mettre tout en pratique.