From fls
How to use Alpine.js for client-side interactivity. Use when adding interactive behaviour to templates such as toggles, dropdowns, modals, expand/collapse, dismissible elements, or any client-side state.
npx claudepluginhub preludetech/django-craftThis skill uses the workspace's default tool permissions.
Use this skill when:
Guides templUI component setup in Go templ apps including dropdowns, dialogs, tabs, toasts; Script() configuration, JS interpolation in handlers, non-responsive fixes, HTML-to-templ conversion, and HTMX/Alpine.js integration.
Provides patterns for Vue components including props validation with TypeScript, defaults, emits, slots, and provide/inject. Use when building reusable Vue components.
Provides best practices and patterns for Laravel Blade components including class-based, anonymous components, slots, attribute bags, and reusable UI like alerts and cards.
Share bugs, ideas, or general feedback.
Use this skill when:
x-data, x-show, x-on, x-bind, x-cloak, x-transition, or x-collapseThis project uses the CSP-compatible build of Alpine.js (@alpinejs/csp), which does NOT support inline JavaScript expressions in directives. All Alpine components must be registered via Alpine.data() in a separate JS file.
Scripts loaded in _base.html:
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.15.8/dist/cdn.min.js"></script>
<script defer src="{% static 'base/js/alpine-components.js' %}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.15.8/dist/cdn.min.js"></script>
Order matters: alpine-components.js loads BEFORE the Alpine CSP script so that Alpine.data() registrations are available when Alpine initialises.
@alpinejs/collapse) -- smooth height-based expand/collapse transitions via x-collapse@alpinejs/persist) is NOT loaded. Use manual localStorage for state persistence (see patterns below).Do not add other plugins without explicit approval.
The @alpinejs/csp build forbids inline JavaScript in Alpine directives. This means:
NOT allowed (will silently fail):
<!-- WRONG: inline expression in x-data -->
<div x-data="{ open: false }">
<!-- WRONG: inline expression in @click -->
<button @click="open = !open">
<!-- WRONG: inline ternary in :class -->
<div :class="open ? 'w-64' : ''">
Correct approach: reference a registered component name in x-data, and call methods defined in that component:
<!-- RIGHT: reference registered component -->
<div x-data="myComponent">
<button x-on:click="toggle">Toggle</button>
<div x-bind:class="widthClass">...</div>
</div>
Every Alpine component must be registered in freedom_ls/base/static/base/js/alpine-components.js inside the alpine:init event listener:
document.addEventListener("alpine:init", () => {
Alpine.data("myComponent", () => ({
// reactive properties
open: false,
// computed-like methods (called from x-bind:class, x-bind:style, etc.)
widthClass() {
return this.open ? "w-64" : "";
},
// methods (called from x-on:click, etc.)
toggle() {
this.open = !this.open;
},
// lifecycle
init() {
// runs when component initialises
},
destroy() {
// runs when component is removed from DOM
},
}));
});
Use Alpine.js for toggling visibility, animations, and local component state. Use HTMX for server communication. They complement each other:
Each component should be self-contained. Avoid sharing state between components. If components need to communicate, prefer HTMX server round-trips or Alpine's $dispatch events.
Alpine.data() registration in freedom_ls/base/static/base/js/alpine-components.jsx-data attributeUse data-* attributes on the element with x-data, then read them in init():
<!-- Template -->
<div x-data="coursePart" data-storage-key="coursePart_{{ course.slug }}_{{ forloop.counter }}">
// alpine-components.js
Alpine.data("coursePart", () => ({
expanded: false,
init() {
const key = this.$el.dataset.storageKey;
if (key) {
this.expanded = localStorage.getItem(key) === "true";
}
},
}));
Since $persist is not available, use manual localStorage in init() and $watch:
Alpine.data("myComponent", () => ({
open: false,
_storageKey: "my-default-key",
init() {
// Allow template to override key via data attribute
this._storageKey = this.$el.dataset.storageKey || "my-default-key";
const stored = localStorage.getItem(this._storageKey);
if (stored !== null) {
this.open = stored === "true";
}
this.$watch("open", (val) => {
localStorage.setItem(this._storageKey, val);
});
},
}));
// alpine-components.js
Alpine.data("toggle", () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
},
}));
<!-- template -->
<div x-data="toggle">
<button x-on:click="toggle">Toggle</button>
<div x-show="open" x-transition>
Content here
</div>
</div>
Since inline ternaries are not allowed, use methods that return class strings:
Alpine.data("sidebar", () => ({
sidebarOpen: false,
sidebarColClass() {
return this.sidebarOpen && !this.isMobile ? "w-64" : "";
},
}));
<div x-bind:class="sidebarColClass">...</div>
Always use x-transition directives for showing/hiding elements. These work the same as standard Alpine since they don't involve JS expressions:
Simple fade:
<div x-show="open" x-transition>...</div>
Custom enter/leave (for overlays, modals, dropdowns):
<div x-show="open"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0">
Scale transitions (for dropdowns):
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95">
Use x-cloak on elements that should be hidden on initial page load to prevent FOUC:
<div x-cloak x-show="sidebarOpen">...</div>
The base CSS already includes [x-cloak] { display: none !important; }.
The Collapse plugin provides smooth height-based animations. Prefer x-collapse over x-show when expanding/collapsing content with variable height:
<div x-data="coursePart">
<button x-on:click="toggleExpanded">Toggle</button>
<div x-show="expanded" x-collapse>
Variable-height content that animates smoothly
</div>
</div>
Use x-collapse.duration.300ms to customise animation speed if needed.
Use Alpine's built-in modifiers (these don't require inline expressions):
<div x-data="dropdownMenu">
<button x-on:click="toggle">Menu</button>
<div x-show="open"
x-on:click.away="close"
x-on:keydown.escape.window="close">
Dropdown content
</div>
</div>
Handle timing in init():
Alpine.data("message", () => ({
show: true,
init() {
setTimeout(() => {
this.show = false;
}, 8000);
},
dismiss() {
this.show = false;
},
}));
Handle in init() with proper cleanup in destroy():
Alpine.data("responsiveComponent", () => ({
isMobile: false,
_mq: null,
_mqHandler: null,
init() {
this._mq = window.matchMedia("(min-width: 1024px)");
this.isMobile = !this._mq.matches;
this._mqHandler = (e) => {
this.isMobile = !e.matches;
};
this._mq.addEventListener("change", this._mqHandler);
},
destroy() {
if (this._mq && this._mqHandler) {
this._mq.removeEventListener("change", this._mqHandler);
}
},
}));
Since <c-icon> is server-rendered, toggle icons with x-show on wrapper <span> elements:
<span x-show="sidebarOpen" x-cloak><c-icon name="menu_close" class="size-5" /></span>
<span x-show="!sidebarOpen"><c-icon name="menu_open" class="size-5" /></span>
Important: x-show with a simple property reference (no expression) works in the CSP build. The CSP restriction applies to expressions like ternaries, assignments, and function calls in directive values — simple property references and method names are allowed.
| Directive | Allowed value | Example |
|---|---|---|
x-data | Registered component name (string) | x-data="sidebarComponent" |
x-show | Property name | x-show="open" |
x-show | Negated property | x-show="!open" |
x-on:click | Method name | x-on:click="toggle" |
x-bind:class | Method name (returns string) | x-bind:class="widthClass" |
x-bind:style | Method name (returns object) | x-bind:style="badgeStyle" |
x-bind:aria-expanded | Property name | x-bind:aria-expanded="open" |
x-model | Property name | x-model="searchQuery" |
x-transition | CSS classes (not JS) | x-transition:enter="ease-out duration-300" |
| Directive | NOT allowed | Why |
|---|---|---|
x-data | x-data="{ open: false }" | Inline object expression |
x-on:click | @click="open = !open" | Inline assignment |
x-bind:class | :class="open ? 'w-64' : ''" | Inline ternary |
x-init | x-init="setTimeout(..." | Inline function call |
Alpine.data() registrations in alpine-components.js, never inline in templatesx-data value must correspond to an Alpine.data() registrationfreedom_ls/base/static/base/js/alpine-components.jslocalStorage in init() + $watch() insteaddata-* attributes + this.$el.dataset in init() to pass Django template values to Alpinex-transition when showing/hiding elementsinit() adds event listeners or observers, add a destroy() to remove them<c-icon> is server-rendered, toggle icons with x-show on wrapper <span> elements (see icon-usage skill)These are already registered in alpine-components.js:
| Component name | Used in | Behaviour |
|---|---|---|
sidebarComponent | _base_interface.html | Toggle open/close, localStorage persistence, responsive mobile/desktop |
dropdownMenu | cotton/dropdown-menu.html | Toggle open/close, click-away, smart positioning |
modal | cotton/modal.html | Toggle open/close, escape key, backdrop click |
message | partials/messages.html | Auto-dismiss toasts |
coursePart | student_interface/partials/course_minimal_toc.html | Expand/collapse with localStorage |
debugBadge | _base.html | Collapsible debug branch badge |
Make sure code is clean and simple