ESC
Type to search...
S
Soli Docs

Client Interactivity

Soli's default project template ships two complementary client-side libraries that, together, cover the bulk of frontend work without a JavaScript build step.

See it in action

Eight live widgets combining HTMx and Alpine on one page — like, search, tabs, inline edit, toasts, polling counter, dismiss, modal.

Live Demo

HTMx v2.0.10

AJAX over HTML attributes — server round-trips that swap fragments into the DOM.

public/js/htmx.min.js · ~50 kB

Alpine.js v3.14.1

Local UI state and behavior — declarative directives for show/hide, tabs, forms, modals.

public/js/alpine.min.js · ~45 kB

Both files load with <script defer> at the bottom of app/views/layouts/application.html.slv. No build step, no CDN dependency, no version drift. Use this combination when Live View (real-time, server-stateful, WebSocket) is heavier than you need — which is most CRUD apps, dashboards, admin pages, and marketing sites.

HTMx v2 note: v2 dropped IE support, removed hx-vars / hx-encoded, and moved WebSocket/SSE to extensions. If you're porting v1 examples from the web, double-check those edges. The directive set used on this page (hx-get, hx-post, hx-target, hx-swap, hx-trigger, hx-push-url) is identical between v1 and v2.

When to Use What

local UI state            →  Alpine          (toggle, tabs, modal, dropdown)
server round-trip         →  HTMx            (submit form, swap fragment)
optimistic UI + server    →  HTMx + Alpine   (flip locally, reconcile on swap)
real-time / multi-user    →  Live View       (WebSocket diffs)

A rough decision tree: if the interaction has no server side, use Alpine alone. If it has a server side but doesn't need WebSocket persistence, use HTMx (and add Alpine if you want optimistic feedback). Reach for Live View when you genuinely need server-pushed updates or multi-client collaboration.

HTMx — AJAX in HTML Attributes

HTMx lets any element fire HTTP requests and swap the response into the DOM. No client-side JavaScript needed:

<button hx-get="/users" hx-target="#user-list" hx-swap="innerHTML">
  Load Users
</button>
<div id="user-list"></div>

Common Directives

  • hx-get="/url", hx-post, hx-put, hx-patch, hx-delete — fire the request.
  • hx-target="#selector" — where the response HTML goes.
  • hx-swap="outerHTML | innerHTML | beforeend | afterbegin | none" — how the response replaces the target.
  • hx-trigger="click | keyup changed delay:300ms | revealed | every 5s" — what fires the request.
  • hx-push-url="true" — update the browser URL on success.

Server Side

The server returns an HTML fragment, not JSON. Soli's respond_to block picks up the HX-Request header automatically:

# Full request → format.html; HTMx submit → format.htmx (partial only).
class PostsController < Controller
  def create(req)
    let post = Post.create(req["all"])
    respond_to(req, fn(format) {
      format.html(fn() redirect("/posts/#{post["id"]}"))
      format.htmx(fn() render("posts/_show_partial", { "post": post }, { "layout": false }))
    })
  end
end

See Controllers — respond_to for the full dispatch order.

Helper Functions (optional)

Long hx-* attributes can be wrapped in helpers if you prefer. These are not built in — drop them in stdlib/ if useful:

fn hx_get(url)
  "hx-get=\"#{url}\""
end

fn hx_target(selector)
  "hx-target=\"#{selector}\""
end

Alpine.js — Local State and Behavior

Alpine layers reactive directives directly onto HTML for state that lives only in the browser:

<div x-data="{ open: false }">
  <button @click="open = !open" class="px-3 py-1 bg-indigo-500 text-white rounded">
    Menu
  </button>
  <ul x-show="open" x-cloak class="mt-2 border rounded p-2">
    <li>Profile</li>
    <li>Sign out</li>
  </ul>
</div>

Add [x-cloak] { display: none !important; } to your CSS and x-cloak to anything that starts hidden — Alpine strips the attribute after parsing, eliminating flash-of-unstyled-content.

Common Directives

  • x-data="{ … }" — defines a reactive scope (its data).
  • x-show="expr" — toggles display: none on the element.
  • x-if="expr" — fully mounts/unmounts (use on <template>).
  • x-model="varName" — two-way binding for <input> / <select> / <textarea>.
  • @click="…", @keydown.enter="…", @submit.prevent="…" — event handlers.
  • :class="…", :disabled="…" — bind attributes to expressions.
  • x-init="…" — run JS once on mount (useful for plugging in SortableJS, Chart.js, etc.).
  • x-ref="name" then this.$refs.name — get a DOM handle without querySelector.

Tabs and Modals

Tabs:

<div x-data="{ tab: 'first' }">
  <nav class="flex gap-4 border-b">
    <button @click="tab = 'first'"  :class="tab === 'first'  && 'font-bold'">First</button>
    <button @click="tab = 'second'" :class="tab === 'second' && 'font-bold'">Second</button>
  </nav>
  <section x-show="tab === 'first'"  x-cloak>First panel.</section>
  <section x-show="tab === 'second'" x-cloak>Second panel.</section>
</div>

Modal (native <dialog>):

<div x-data="{ open: false }">
  <button @click="open = true">Open</button>
  <dialog :open="open" @close="open = false" class="rounded-lg p-4">
    <p>Body text…</p>
    <button @click="open = false">Close</button>
  </dialog>
</div>

Client-Side Validation

Pair x-model with derived state to give immediate feedback. Server-side V.validate(...) (see Validation) remains the source of truth — Alpine just fails fast in the UI.

<form x-data="{ email: '', get valid() { return /.+@.+\..+/.test(this.email) } }"
      action="/signup" method="post">
  <input type="email" name="email" x-model="email" class="border rounded px-2 py-1" />
  <p x-show="email && !valid" x-cloak class="text-red-600 text-sm">
    Enter a valid email address.
  </p>
  <button :disabled="!valid"
          class="px-3 py-1 bg-indigo-500 text-white rounded disabled:opacity-50">
    Sign up
  </button>
</form>

Combined Patterns

Optimistic UI

Wrap an HTMx-driven button in x-data and flip local state on click. HTMx then swaps the button with the server's authoritative render:

<button x-data="{ liked: false }"
        @click="liked = true"
        :class="liked && 'text-pink-500'"
        hx-post="/posts/42/like"
        hx-swap="outerHTML">
  ♥ Like
</button>

If the request fails, the server's response replaces the optimistic state. For finer error handling, listen for HTMx events:

<div x-data="{ status: 'idle' }"
     @htmx:before-request.window="status = 'loading'"
     @htmx:after-request.window="status = 'idle'"
     @htmx:response-error.window="status = 'failed'">
  <span x-show="status === 'loading'" x-cloak>Saving…</span>
  <span x-show="status === 'failed'"  x-cloak class="text-red-600">Save failed.</span>
</div>

Swap, Then Re-Init

When HTMx swaps content into the page, Alpine's built-in MutationObserver re-binds any x-data blocks inside the new fragment automatically. You only need to wire something manually when the swapped fragment hosts a third-party widget (e.g. Sortable, Chart, a datepicker) — dispatch a custom event in @htmx:after-swap and listen for it elsewhere.

Mounting External Libraries

Soli does not bundle SortableJS, Chart.js, or other heavy libraries — drop them in public/js/ (or a CDN) and let Alpine drive their lifecycle:

<ul x-data
    x-init="new Sortable($el, { animation: 150,
      onEnd(e) { fetch('/items/reorder', { method: 'POST',
        body: JSON.stringify({ from: e.oldIndex, to: e.newIndex }),
        headers: { 'Content-Type': 'application/json' } }) } })"
    class="space-y-2">
  <% for item in items %>
    <li class="bg-white p-2 rounded shadow cursor-move"><%= item["name"] %></li>
  <% end %>
</ul>
<canvas x-data="{
          init() {
            new Chart(this.$el, { type: 'line', data: <%= chart_data %> })
          }
        }"></canvas>

Coexistence Notes

  • HTMx + Alpine share the same DOM and work together cleanly. HTMx uses hx-*, Alpine uses x-* — no namespace collision.
  • Live View containers re-render the DOM and reset any Alpine state held inside on each push. Keep local widget state outside Live View regions; for state that must survive a re-render, lift it into Live View.
  • The dev bar automatically skips itself on HTMx partial responses (reads the HX-Request header) so swaps don't accumulate stacked dev bars on the page. No configuration needed.

Upgrading or Replacing

Pinned versions live at the top of public/js/htmx.min.js and public/js/alpine.min.js. To upgrade:

  1. Download a newer build (cdn.jsdelivr.net/npm/htmx.org@<version>/dist/htmx.min.js, cdn.jsdelivr.net/npm/alpinejs@<version>/dist/cdn.min.js).
  2. Replace the file. Update the banner comment so future readers know what version is in.
  3. Run the smoke test in the Verification section of the docs source.

To opt out of either library entirely, delete the file and remove the matching <script> tag from application.html.slv. Nothing in the framework requires either of them.