elementary-alpine

0.3.000

Alpine.js + Elementary: Reactive web apps with Swift
amirsaam/elementary-alpinejs

What's New

v0.3.000

2026-06-19T15:22:56Z

Maybe the first version to call as usable? 😅

  • Added setupAlpine helper
  • Refactored some APIs
  • Restructured files and folders
  • Updated README.md documentation
  • Refreshed AGENTS.md for AI Development

Full Changelog: https://github.com/amirsaam/elementary-alpine/compare/0.2.600...0.3.000

ElementaryAlpine: Reactive web apps with Swift

Ergonomic AlpineJS extensions for Elementary

import Elementary
import ElementaryAlpine

struct CounterPage: HTMLDocument {
    var head: some HTML {
        meta(.charset(.utf8))
        title("Counter")
        setupAlpine()
    }
    var body: some HTML {
        main(.class("container")) {
            div(.x.data("{ count: 0 }"), .class("counter")) {
                button(.x.on("click", "count--")) { "−" }
                span(.x.text("count")) { "0" }
                button(.x.on("click", "count++")) { "+" }
                button(.x.on("click", "count = 0"), .x.show("count > 0")) { "Reset" }
            }
        }
    }
}

Generated HTML:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Counter</title>
    <script
      src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js"
      defer
    ></script>
  </head>
  <body>
    <main class="container">
      <div x-data="{ count: 0 }" class="counter">
        <button x-on:click="count--">−</button>
        <span x-text="count">0</span>
        <button x-on:click="count++">+</button>
        <button x-on:click="count = 0" x-show="count > 0">Reset</button>
      </div>
    </main>
  </body>
</html>

Use it

Add ElementaryAlpine to your Package.swift dependencies:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "MyApp",
    dependencies: [
        .package(url: "https://github.com/elementary-swift/elementary-alpine", from: "0.3.0"),
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "ElementaryAlpine", package: "elementary-alpine"),
            ]
        ),
    ]
)

To use Alpine.js plugins, add the ElementaryAlpinePlugins product:

.product(name: "ElementaryAlpinePlugins", package: "elementary-alpine"),

This package is built against Alpine.js v3 (pinned to 3.15.12).

Play with it

Example apps will be added in a future.

Quick tour

// first-class support for all AlpineJS directives
div(.x.data("{ count: 0 }"), .class("counter")) {
    button(.x.on("click", "count++")) { "Increment" }
    span(.x.text("count")) { "0" }
}
// bind attributes reactively
input(.x.bind("placeholder", "text"), .type(.text))

// bind class with object syntax
div(.x.bindClass("{ 'hidden': !open }"))

// bind style with object syntax
div(.x.bindStyle("{ color: 'red' }"))
// event modifiers (passed as a modifiers array)
form(.x.on("submit", "...", modifiers: [.prevent])) {
    button { "Submit" }
}

// keyboard modifiers
input(.x.on("keyup", "submit()", modifiers: [.enter]))

// debounce / throttle (ms)
input(.x.on("input", "fetchResults()", modifiers: [.debounce(500)]))
// two-way binding
input(.x.model("search"))

// model with modifiers
input(.x.model("search", modifiers: [.number, .debounce(300)]))
// loops (semantic name for x-for)
template(.x.loop("item in items")) {
    li(.x.text("item")) { "" }
}

// conditional rendering (semantic name for x-if)
template(.x.when("open")) {
    div { "Content" }
}

// transitions with modifiers
div(.x.show("open"), .x.transition(modifiers: [.scale(80), .origin(.top)])) {
    "Content"
}

Globals

Alpine.js global APIs (Alpine.data, Alpine.store, Alpine.bind) are available as registerGlobal for registering reusable components, stores, and bound directives. The action body is a trailing closure returning a JavaScript expression:

import ElementaryAlpine

// In your head:
registerGlobal(.data, on: "dropdown") {
    "() => ({ open: false, toggle() { this.open = !this.open } })"
}
registerGlobal(.store, on: "notifications") {
    "{ items: [] }"
}
registerGlobal(.bind, on: "myButton") {
    "() => ({ type: 'button' })"
}

Generated HTML:

<script>
  document.addEventListener('alpine:init', () => {
    Alpine.data('dropdown', () => ({
      open: false,
      toggle() {
        this.open = !this.open
      },
    }))
  })
</script>
<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('notifications', { items: [] })
  })
</script>
<script>
  document.addEventListener('alpine:init', () => {
    Alpine.bind('myButton', () => ({ type: 'button' }))
  })
</script>

API:

Function Alpine.js call Use case
registerGlobal(.data, on:) { ... } Alpine.data(name, factory) Reusable component data (factory function)
registerGlobal(.store, on:) { ... } Alpine.store(name, value) Global reactive store (direct object)
registerGlobal(.bind, on:) { ... } Alpine.bind(name, factory) Reusable x-bind object (factory function)

Setup

The setupAlpine(version:plugins:) helper generates the <script> tags needed to install Alpine.js and any plugins from a CDN. Plugin scripts are emitted first (per Alpine.js requirements), followed by Alpine core.

Without plugins:

var head: some HTML {
    meta(.charset(.utf8))
    setupAlpine()
}

With plugins:

var head: some HTML {
    meta(.charset(.utf8))
    setupAlpine(plugins: [.mask, .focus, .morph])
}

Generated HTML:

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

Available plugins (use the AlpinePlugin enum cases):

Plugin Enum case Source
Mask .mask @alpinejs/mask
Intersect .intersect @alpinejs/intersect
Resize .resize @alpinejs/resize
Persist .persist @alpinejs/persist
Focus .focus @alpinejs/focus
Collapse .collapse @alpinejs/collapse
Anchor .anchor @alpinejs/anchor
Sort .sort @alpinejs/sort
Morph .morph @alpinejs/morph

Custom version:

setupAlpine(version: "3.14.0", plugins: [.focus])

Including Alpine.js manually

setupAlpine always emits jsDelivr CDN URLs. If you need to host Alpine.js yourself, use a different CDN, or self-host your plugin scripts, write the <script> tags directly. Plugin scripts must be loaded before Alpine core — per the Alpine.js docs, the core script hooks into the plugin instances at startup.

From a CDN:

var head: some HTML {
    meta(.charset(.utf8))
    script(.src("https://cdn.jsdelivr.net/npm/alpinejs@3.15.12/dist/cdn.min.js"), .defer) {}
}

From a local file:

Download alpine.min.js from the Alpine.js releases and place it in your project's Public/ (or equivalent served directory). For Hummingbird/Vapor, declare the resource in Package.swift:

.executableTarget(
    name: "App",
    // ...
    resources: [
        .copy("Public")
    ]
)

Then reference it by URL path:

var head: some HTML {
    meta(.charset(.utf8))
    script(.src("/alpine.min.js"), .defer) {}
}

The same pattern applies to plugin scripts — download each @alpinejs/<plugin>@3.15.12 release, place the cdn.min.js in Public/, and load them in plugin-first order.

Modifiers

Directives that support modifiers take a modifiers: array parameter with a typed enum value:

// x-show
.x.show("open", modifiers: [.important])              // → x-show.important="open"

// x-on
.x.on("click", "...", modifiers: [.prevent, .stop])  // → x-on:click.prevent.stop="..."
.x.on("keyup", "...", modifiers: [.enter])            // → x-on:keyup.enter="..."
.x.on("input", "...", modifiers: [.debounce(500)])    // → x-on:input.debounce.500ms="..."
.x.on("click", "...", modifiers: [.selfTarget])       // → x-on:click.self="..."

// x-model
.x.model("search", modifiers: [.number, .change, .blur, .enter])

// x-transition
.x.transition(modifiers: [.opacity])
.x.transition(modifiers: [.scale(80), .origin(.topRight)])
.x.transition(modifiers: [.duration(500), .delay(50)])

Magics

Alpine.js magics are JS-side helpers that exist inside Alpine expressions. They don't generate HTML attributes or scripts — they appear as string literals in directive values:

// $dispatch — dispatch a custom event
button(.x.on("click", "$dispatch('notify')")) { "Notify" }

// $store — access a global store
div(.x.text("$store.user.name"))

// $refs — reference an element by key
input(.x.ref("myInput"), .type(.text))
button(.x.on("click", "$refs.myInput.focus()")) { "Focus" }

// $watch — reactively watch a property
div(.x.setup("count = 0; $watch('count', value => console.log(value))"))

// $nextTick — wait for next DOM update
div(.x.setup("$nextTick(() => console.log('mounted')"))

// $el, $root, $data, $id — context accessors
div(.x.data("{ open: false }"), .x.text("$el.tagName"))

Available magics: $el, $refs, $store, $watch, $dispatch, $nextTick, $root, $data, $id, $persist (requires the Persist plugin)

No code or attributes are needed for magics — just use the magic name as a string in any Alpine expression.

Plugins

Alpine.js plugins extend the runtime with additional directives. This package ships a separate library, ElementaryAlpinePlugins, that exposes them as Swift attribute helpers.

Alpine.js plugin scripts depend on Alpine.js core. At the Swift level, ElementaryAlpinePlugins has no compile-time dependency on ElementaryAlpine — both libraries only depend on Elementary. The dependency exists at the JavaScript runtime level: plugin CDN scripts hook into the core Alpine instance, so the plugin script tag must be present in your page (and load before Alpine core, per the Alpine.js docs).

Use setupAlpine(plugins:) to install the script tags for the plugins you use — plugin scripts are emitted before Alpine core, in the correct order:

var head: some HTML {
    meta(.charset(.utf8))
    setupAlpine(plugins: [.mask, .focus, .morph])
}

If you need to host plugin scripts yourself or use a non-jsDelivr CDN, see Including Alpine.js manually above.

Mask

Mask formats text input as the user types. Useful for phone numbers, credit cards, dates, account numbers, etc.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Static pattern — wildcards: 9 (numeric), a (alpha), * (any)
input(.xMask.pattern("99/99/9999"), .x.model("date"))
input(.xMask.pattern("(999) 999-9999"), .x.model("phone"))

// Dynamic mask — expression receives $input
input(.xMask.dynamic("$money($input)"), .x.model("amount"))

// Dynamic mask — function reference
input(.xMask.dynamic("creditCardMask"), .x.model("card"))

Generated HTML:

<input x-mask="99/99/9999" x-model="date" />
<input x-mask="(999) 999-9999" x-model="phone" />
<input x-mask:dynamic="$money($input)" x-model="amount" />
<input x-mask:dynamic="creditCardMask" x-model="card" />

Notes:

  • x-mask:dynamic accepts a JavaScript expression or a function name. The expression receives $input (the current input value) as a magic.
  • The built-in $money($input, '.', ',', 4) helper handles currency formatting with optional custom decimal/thousands separators and precision. Pass it as the directive value — no Swift modifier is needed.
  • The Mask plugin has no HTML modifiers in Alpine.js, so MaskDynamicModifier does not exist. All configuration happens in the value string.

Intersect

Intersect is a convenience wrapper for the Intersection Observer API. It runs an expression when an element enters or leaves the viewport — useful for lazy loading, infinite scroll, "view" tracking, etc.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Trigger when the element enters the viewport
div(.xIntersect.intersect("shown = true")) {
    "I'm in the viewport!"
}

// Trigger only the first time
div(.xIntersect.intersect("loaded = true", modifiers: [.once])) {
    "..."
}

// Trigger when at least half of the element is visible
div(.xIntersect.intersect("loaded = true", modifiers: [.half])) {
    "..."
}

// Trigger when the whole element is visible
div(.xIntersect.intersect("loaded = true", modifiers: [.full])) {
    "..."
}

// Custom threshold (0–100, percentage of element visible)
div(.xIntersect.intersect("loaded = true", modifiers: [.threshold(50)])) {
    "..."
}

// Expand the viewport boundary (CSS-margin syntax)
div(.xIntersect.intersect("loaded = true", modifiers: [.margin("200px")])) {
    "..."
}

// Trigger on enter (alias of x-intersect)
div(.xIntersect.enter("shown = true")) { "..." }

// Trigger on leave
div(.xIntersect.leave("shown = false")) { "..." }

// Chained modifiers
div(.xIntersect.intersect("loaded = true", modifiers: [.threshold(50), .full])) {
    "..."
}

Generated HTML:

<div x-intersect="shown = true">I'm in the viewport!</div>
<div x-intersect.once="loaded = true">...</div>
<div x-intersect.half="loaded = true">...</div>
<div x-intersect.full="loaded = true">...</div>
<div x-intersect.threshold.50="loaded = true">...</div>
<div x-intersect.margin.200px="loaded = true">...</div>
<div x-intersect:enter="shown = true">...</div>
<div x-intersect:leave="shown = false">...</div>
<div x-intersect.threshold.50.full="loaded = true">...</div>

Modifier reference:

Modifier Raw value Notes
.once once Fire only the first time
.half half Fire at 50% visibility
.full full Fire at 99% visibility
.threshold(Int) threshold.N Custom percentage (0–100)
.margin(String) margin.<css-margin> Expand/contract viewport boundary (CSS-margin syntax)

Resize

Resize is a convenience wrapper for the Resize Observer API. It exposes $width and $height magics whenever an element changes size.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Track an element's size
div(.xResize.resize("width = $width; height = $height")) {
    p(.x.text("'Width: ' + width + 'px'")) { "" }
    p(.x.text("'Height: ' + height + 'px'")) { "" }
}

// Track the entire document
div(.xResize.resize("width = $width; height = $height", modifiers: [.document])) { ... }

Generated HTML:

<div x-resize="width = $width; height = $height">
  <p x-text="'Width: ' + width + 'px'"></p>
  <p x-text="'Height: ' + height + 'px'"></p>
</div>
<div x-resize.document="width = $width; height = $height">...</div>

Modifier reference:

Modifier Raw value Notes
.document document Observe the document instead of a specific element

Persist

Persist saves Alpine state to localStorage (or sessionStorage) so values persist across page loads. Useful for search filters, active tabs, theme preferences, and other state that users expect to survive a refresh.

Unlike the other plugins, Persist is a magic, not a directive — there is no x-persist HTML attribute. The API is the $persist(...) function used inside x-data values.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Persist a counter to localStorage
div(.x.data("{ count: $persist(0) }")) {
    button(.x.on("click", "count++")) { "Increment" }
    span(.x.text("count")) { "" }
}

// Use a custom localStorage key
div(.x.data("{ count: $persist(0).as('my-count') }")) {
    button(.x.on("click", "count++")) { "Increment" }
    span(.x.text("count")) { "" }
}

// Use sessionStorage instead (cleared when the tab closes)
div(.x.data("{ count: $persist(0).using(sessionStorage) }")) {
    button(.x.on("click", "count++")) { "Increment" }
    span(.x.text("count")) { "" }
}

Generated HTML:

<div x-data="{ count: $persist(0) }">
  <button x-on:click="count++">Increment</button>
  <span x-text="count"></span>
</div>
<div x-data="{ count: $persist(0).as('my-count') }">
  <button x-on:click="count++">Increment</button>
  <span x-text="count"></span>
</div>
<div x-data="{ count: $persist(0).using(sessionStorage) }">
  <button x-on:click="count++">Increment</button>
  <span x-text="count"></span>
</div>

Notes:

  • Persist is not a directive, so there is no HTMLAttribute helper. Write $persist(...) as a JS string in your x-data value.
  • .as(...) and .using(...) are JavaScript method calls on the $persist(...) return value, not HTML modifiers — they cannot be type-safe in Swift.
  • $persist works with primitives, arrays, and objects. If you change the type of a persisted value, clear its localStorage entry first.

Focus

Focus lets you manage focus on a page, including trapping focus within an element (for modals/dialogs), navigating focus with arrow keys, and more.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Trap focus inside an element while `open` is true
div(.x.data("{ open: false }")) {
    button(.x.on("click", "open = true")) { "Open Dialog" }
    span(.x.show("open"), .xFocus.trap("open")) {
        p { "..." }
        input(.type(.text), .placeholder("Some input..."))
        input(.type(.text), .placeholder("Some other input..."))
        button(.x.on("click", "open = false")) { "Close Dialog" }
    }
}

// Hide all other elements from screen readers while trapped
div(.xFocus.trap("open", modifiers: [.inert])) { ... }

// Disable page scroll while trapped
div(.xFocus.trap("open", modifiers: [.noscroll])) { ... }

// Don't return focus to the previous element on untrap
div(.xFocus.trap("open", modifiers: [.noreturn])) { ... }

// Don't auto-focus the first focusable element on trap
div(.xFocus.trap("open", modifiers: [.noautofocus])) { ... }

// Chained modifiers
div(.xFocus.trap("open", modifiers: [.inert, .noscroll, .noreturn])) { ... }

Generated HTML:

<div x-trap="open">...</div>
<div x-trap.inert="open">...</div>
<div x-trap.noscroll="open">...</div>
<div x-trap.noreturn="open">...</div>
<div x-trap.noautofocus="open">...</div>
<div x-trap.inert.noscroll.noreturn="open">...</div>

Modifier reference:

Modifier Raw value Notes
.inert inert Mark other page elements aria-hidden="true" while trapped
.noscroll noscroll Block page scrolling while trapped
.noreturn noreturn Don't return focus on untrap
.noautofocus noautofocus Don't auto-focus the first focusable element

Notes:

  • The Focus plugin also provides a $focus magic (.next(), .previous(), .wrap(), .first(), .last(), etc.) used as JS strings inside x-on handlers — no Swift helper needed.
  • The Focus plugin was previously called "Trap" — x-trap and its modifiers are unchanged.

Collapse

Collapse expands and collapses elements with smooth height animations. Unlike x-transition, x-collapse is dedicated to height-based collapse and works alongside x-show.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Basic collapse (works with x-show)
p(.x.show("expanded"), .xCollapse.collapse()) {
    "..."
}

// Custom duration (ms)
p(.x.show("expanded"), .xCollapse.collapse(modifiers: [.duration(1000)])) {
    "..."
}

// Minimum collapsed height (px) — useful for "cut-off" instead of full hide
p(.x.show("expanded"), .xCollapse.collapse(modifiers: [.min(50)])) {
    "..."
}

// Chained modifiers
p(.x.show("expanded"), .xCollapse.collapse(modifiers: [.duration(500), .min(50)])) {
    "..."
}

Generated HTML:

<p x-show="expanded" x-collapse>...</p>
<p x-show="expanded" x-collapse.duration.1000ms>...</p>
<p x-show="expanded" x-collapse.min.50px>...</p>
<p x-show="expanded" x-collapse.duration.500ms.min.50px>...</p>

Modifier reference:

Modifier Raw value Notes
.duration(Int) duration.Nms Animation duration in milliseconds
.min(Int) min.Npx Minimum collapsed height in pixels (cuts off rather than fully hides)

Notes:

  • x-collapse can only exist on an element that already has x-show. It animates the height property when x-show toggles visibility.
  • x-collapse has no value — it only accepts modifiers.

Anchor

Anchor anchors an element's positioning to another element on the page. Built on top of Floating UI, it powers dropdowns, popovers, tooltips, and dialogs.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Anchor below the button (default positioning)
div(.x.data("{ open: false }")) {
    button(.x.ref("button"), .x.on("click", "open = ! open")) { "Toggle" }
    div(.x.show("open"), .xAnchor.anchor("$refs.button")) {
        "Dropdown content"
    }
}

// Anchor below-right of the button
div(.x.show("open"), .xAnchor.anchor("$refs.button", modifiers: [.bottomStart])) {
    "Dropdown content"
}

// Use fixed positioning (escapes overflow:hidden containers)
div(.x.show("open"), .xAnchor.anchor("$refs.button", modifiers: [.fixed])) {
    "Dropdown content"
}

// Add an offset (px)
div(.x.show("open"), .xAnchor.anchor("$refs.button", modifiers: [.offset(10)])) {
    "Dropdown content"
}

// Prevent auto-flip when there's no room below
div(.x.show("open"), .xAnchor.anchor("$refs.button", modifiers: [.noflip])) {
    "Dropdown content"
}

// Apply positioning yourself via $anchor.x/$anchor.y in x-bind:style
div(
    .x.show("open"),
    .xAnchor.anchor("$refs.button", modifiers: [.noStyle]),
    .x.bindStyle("{ position: 'absolute', top: $anchor.y+'px', left: $anchor.x+'px' }")
) {
    "Dropdown content"
}

// Anchor to an element by id
div(.x.show("open"), .xAnchor.anchor("document.getElementById('trigger')")) {
    "Dropdown content"
}

Generated HTML:

<div x-anchor="$refs.button">Dropdown content</div>
<div x-anchor.bottom-start="$refs.button">Dropdown content</div>
<div x-anchor.fixed="$refs.button">Dropdown content</div>
<div x-anchor.offset.10="$refs.button">Dropdown content</div>
<div x-anchor.noflip="$refs.button">Dropdown content</div>
<div x-anchor.no-style="$refs.button" x-bind:style="...">Dropdown content</div>
<div x-anchor="document.getElementById('trigger')">Dropdown content</div>

Positioning modifiers:

Modifier Raw value Notes
.top top Above the reference, centered
.topStart top-start Above the reference, aligned to the start
.topEnd top-end Above the reference, aligned to the end
.bottom bottom Below the reference, centered
.bottomStart bottom-start Below the reference, aligned to the start
.bottomEnd bottom-end Below the reference, aligned to the end
.left left Left of the reference, centered
.leftStart left-start Left of the reference, aligned to the start
.leftEnd left-end Left of the reference, aligned to the end
.right right Right of the reference, centered
.rightStart right-start Right of the reference, aligned to the start
.rightEnd right-end Right of the reference, aligned to the end

Other modifiers:

Modifier Raw value Notes
.fixed fixed Use position: fixed (escapes overflow: hidden containers)
.offset(Int) offset.N Spacing in pixels between anchored and reference element
.noflip noflip Don't auto-flip when there's no room in the chosen direction
.noStyle no-style Don't apply positioning styles; access them via $anchor.x / $anchor.y in x-bind:style

Notes:

  • x-anchor is a thin wrapper around Floating UI. For advanced configuration not exposed by the modifiers, use x-anchor.noStyle and apply styles yourself via x-bind:style and the $anchor magic.
  • A transform, filter, perspective, backdrop-filter, will-change, or contain on any ancestor creates a new containing block for position: fixed descendants. .fixed will behave like position: absolute relative to that ancestor.

Sort

Sort lets you re-order elements by dragging them with your mouse. Built on top of SortableJS, it powers Kanban boards, to-do lists, sortable table columns, and more.

Usage:

import Elementary
import ElementaryAlpine
import ElementaryAlpinePlugins

// Basic sortable list
ul(.xSort.sort) {
    li(.xSort.item("1")) { "foo" }
    li(.xSort.item("2")) { "bar" }
    li(.xSort.item("3")) { "baz" }
}

// Sort with a handler that runs on every reorder
ul(.xSort.sort("alert($item + ' - ' + $position)")) {
    li(.xSort.item("1")) { "foo" }
    li(.xSort.item("2")) { "bar" }
    li(.xSort.item("3")) { "baz" }
}

// Group sortable lists — items can be dragged between lists with the same group
ul(.xSort.sort("handle"), .xSort.group("todos")) {
    li(.xSort.item("1")) { "foo" }
    li(.xSort.item("2")) { "bar" }
    li(.xSort.item("3")) { "baz" }
}

ol(.xSort.sort("handle"), .xSort.group("todos")) {
    li(.xSort.item("4")) { "foo" }
    li(.xSort.item("5")) { "bar" }
    li(.xSort.item("6")) { "baz" }
}

// Drag handles — only the handle initiates drag
ul(.xSort.sort) {
    li(.xSort.item("1")) {
        span(.xSort.handle) { " - " }
        "foo"
    }
    li(.xSort.item("2")) {
        span(.xSort.handle) { " - " }
        "bar"
    }
}

// Ignore elements — buttons inside items stay clickable
ul(.xSort.sort) {
    li(.xSort.item("1")) {
        "foo"
        button(.xSort.ignore) { "Edit" }
    }
}

// Show a ghost of the dragged element instead of an empty space
ul(.xSort.sort(modifiers: [.ghost])) {
    li(.xSort.item("1")) { "foo" }
    li(.xSort.item("2")) { "bar" }
}

// Pass custom SortableJS options
ul(.xSort.sort, .xSort.config("{ animation: 0 }")) {
    li(.xSort.item("1")) { "foo" }
}

Generated HTML:

<ul x-sort>
  <li x-sort:item="1">foo</li>
  <li x-sort:item="2">bar</li>
  <li x-sort:item="3">baz</li>
</ul>

<ul x-sort="alert($item + ' - ' + $position)">
  <li x-sort:item="1">foo</li>
  <li x-sort:item="2">bar</li>
  <li x-sort:item="3">baz</li>
</ul>

<ul x-sort="handle" x-sort:group="todos">
  <li x-sort:item="1">foo</li>
  <li x-sort:item="2">bar</li>
  <li x-sort:item="3">baz</li>
</ul>

<ol x-sort="handle" x-sort:group="todos">
  <li x-sort:item="4">foo</li>
  <li x-sort:item="5">bar</li>
  <li x-sort:item="6">baz</li>
</ol>

<ul x-sort>
  <li x-sort:item="1"><span x-sort:handle> - </span>foo</li>
  <li x-sort:item="2"><span x-sort:handle> - </span>bar</li>
</ul>

<ul x-sort>
  <li x-sort:item="1">
    foo
    <button x-sort:ignore>Edit</button>
  </li>
</ul>

<ul x-sort.ghost>
  <li x-sort:item="1">foo</li>
  <li x-sort:item="2">bar</li>
</ul>

<ul x-sort x-sort:config="{ animation: 0 }">
  <li x-sort:item="1">foo</li>
</ul>

Modifier reference:

Modifier Raw value Notes
.ghost ghost Show a ghost of the dragged element in its place (default: empty hole)

Notes:

  • The Sort handler is called every time sort order changes. Inside the handler, $item is the moved item's key (from x-sort:item) and $position is its new index (starting at 0). The handler can also be a function reference that receives (item, position) as arguments.
  • x-sort:item keys are typically numeric ("1", "2", …) but can be any string used to identify the item.
  • x-sort:group lets you drag items between lists. When using .as handlers with cross-group drag, only the destination list's handler is called.
  • x-sort:config accepts any SortableJS options. Be aware that overwriting handle, group, filter, onSort, onStart, or onEnd may break functionality.
  • While dragging, Alpine adds a .sorting class to <body> — useful for conditional CSS like body.sorting #warning { display: block; }.

Morph

Morph lets you update a section of the DOM with new HTML while preserving Alpine state, browser focus, scroll position, and form input values. Useful for server-rendered updates (Livewire, LiveView) or any time you want to swap content without losing state.

Unlike the other plugins, Morph provides two factory functions that generate a <script> element with the right JS to call Alpine.morph or Alpine.morphBetween.

setupMorph — replaces a single element with new HTML on a trigger event.

setupMorph(
    trigger: "#refresh-btn",
    target: "#user-card",
    event: "click"
) {
    div(.x.data("{ name: 'Updated' }")) {
        h2 { "Updated content" }
    }
}

Generated HTML:

<script>
  document.querySelector('#refresh-btn').addEventListener('click', async () => {
    Alpine.morph(
      document.querySelector('#user-card'),
      `<div x-data="{ name: 'Updated' }"><h2>Updated content</h2></div>`,
    )
  })
</script>

setupMorphBetween — morphs the content between two marker nodes (typically HTML comments).

// In your body, place markers around the content you want to update:
// <!--list-start-->
// <li>item</li>
// <!--list-end-->

setupMorphBetween(
    trigger: "#refresh",
    startMarker: "<!--list-start-->",
    endMarker: "<!--list-end-->",
    event: "click"
) {
    li { "new item" }
}

The generated JS includes a findMorphMarker helper that handles both CSS selectors (#start, .end) and HTML comment markers (<!--start-->, <!--end-->), so you can use whichever style matches your markup.

Generated HTML:

<script>
  const findMorphMarker = (marker) => {
    let el = document.querySelector(marker)
    if (el) return el
    if (marker.startsWith('<!--') && marker.endsWith('-->')) {
      const text = marker.slice(4, -3).trim()
      const walker = document.createTreeWalker(
        document.body,
        NodeFilter.SHOW_COMMENT,
      )
      let node
      while ((node = walker.nextNode())) {
        if (node.nodeValue && node.nodeValue.trim() === text) return node
      }
    }
    return null
  }
  document.querySelector('#refresh').addEventListener('click', async () => {
    Alpine.morphBetween(
      findMorphMarker('<!--list-start-->'),
      findMorphMarker('<!--list-end-->'),
      `<li>new item</li>`,
    )
  })
</script>

Options (lifecycle hooks + key + lookahead):

Both functions accept an options: builder for configuring the morph. The builder closure returns a MorphOptions value, built by chaining lifecycle methods. Each method takes a trailing closure returning a JavaScript expression:

setupMorph(
    trigger: "#list-refresh",
    target: "#list",
    event: "click"
) {
    .updating { "console.log('updating', el)" }
    .key { "(el) => el.id" }
    .lookahead()
} returning: {
    ul { li { "item" } }
}

Available options (same for both setupMorph and setupMorphBetween):

Method Alpine.js option Description
.updating { } updating(el, toEl, childrenOnly, skip) Called before patching
.updated { } updated(el, toEl) Called after patching
.removing { } removing(el, skip) Called before removing
.removed { } removed(el) Called after removing
.adding { } adding(el, skip) Called before adding
.added { } added(el) Called after adding
.key { } key(el) Function returning the element's key
.lookahead() lookahead: true Enable lookahead algorithm

Dynamic HTML (fetch):

Use the jsCommand: parameter to dynamically compute the HTML (e.g., from a fetch). The closure must assign the HTML string to a variable named html:

setupMorph(
    trigger: "#refresh-btn",
    target: "#list",
    event: "click"
) {
    "const html = await fetch('/api/list').then(r => r.text())"
} returning: {
    div { "default" }
}

All three parameters (options + jsCommand + returning):

Combine all three when you need lifecycle hooks, dynamic HTML, and a fallback template:

setupMorph(
    trigger: "#refresh-btn",
    target: "#list",
    event: "click"
) {
    .updating { "console.log('updating', el)" }
    .key { "(el) => el.id" }
    .lookahead()
} jsCommand: {
    "const html = await fetch('/api/list').then(r => r.text())"
} returning: {
    div { "default" }
}

Without trigger (imperative):

Both functions have overloads without trigger/event parameters — they emit a script that runs the morph once on page load:

setupMorph(target: "#content") {
    div { "initial content" }
}

setupMorphBetween(
    startMarker: "<!--start-->",
    endMarker: "<!--end-->"
) {
    li { "initial" }
}

Notes:

  • For more complex morph scenarios (e.g., custom element resolution, exotic comment markers), use a raw <script> block instead.

Documentation

The package ships two libraries:

  • ElementaryAlpine — core:
    • Attribute helpers via the .x syntax on all HTMLElements for all 18 core AlpineJS directives:
      • x-data, x-init (.setup), x-show
      • x-bind / x-bind:class / x-bind:style
      • x-on with modifiers (base, keyboard, mouse, advanced)
      • x-text, x-html, x-model with modifiers, x-modelable
      • x-for (.loop), x-transition (all phases), x-effect, x-ignore, x-ref, x-cloak
      • x-teleport, x-if (.when), x-id
    • Global helpers — registerGlobal(_:on:action:) for Alpine.data(), Alpine.store(), Alpine.bind() (see Globals)
    • CDN helper — setupAlpine(version:plugins:) emits the <script> tags for Alpine.js + plugins (see Setup)
  • ElementaryAlpinePlugins — Alpine.js plugin wrappers (see Plugins). Currently ships Mask (.xMask.pattern / .xMask.dynamic), Intersect (.xIntersect.intersect / .enter / .leave), Resize (.xResize.resize), Persist (the $persist magic — no directive surface), Focus (.xFocus.trap), Collapse (.xCollapse.collapse), Anchor (.xAnchor.anchor), Sort (.xSort.sort / .item / .group / .handle / .ignore / .config), and Morph (setupMorph + setupMorphBetween).

For contribution guidelines and architecture details, see AGENTS.md.

Future directions

  • All 9 official Alpine.js plugins are now documented. Future work: example apps and ecosystem integrations.

PRs welcome.

License

Apache 2.0

Description

  • Swift Tools 6.0.0
View More Packages from this Author

Dependencies

Last updated: Sun Jun 21 2026 21:07:32 GMT-0900 (Hawaii-Aleutian Daylight Time)