I was adding two features to the dashboard of a sports tournament management app:

  1. DnD card reordering — drag cards to rearrange sections (My Matches / Bracket / Match List)
  2. Card collapse/expand — fold away sections you don’t need

Each sounds simple, but add Turbo Frame lazy loading and a requirement that layout state survives page reloads, and there’s more to think about than it first appears.


1. Choosing a DnD Library

My first attempt used the native HTML5 Drag & Drop API directly — dragstart, dragover, drop. It works fine on desktop, but the problem is touch devices. The HTML5 drag API has incomplete support on iOS Safari; touch-dragging simply doesn’t work there.

Since mobile is the primary platform, this was a non-starter.

Libraries I evaluated:

LibrarySize (gzip)TouchStimulus integrationNotes
SortableJS~10KB✅ FullVery easyRails community standard
Dragula~5KB⚠️ PartialOKWeak multi-container support
Interact.js~25KB✅ FullComplexGood when resize is also needed
Pragmatic DnD~15KB✅ FullComplexGreat accessibility, by Atlassian

Went with SortableJS — most battle-tested in the Rails/Hotwire ecosystem and a natural fit for Stimulus controllers.

Adding via importmap

Pin the CDN ESM version:

# config/importmap.rb
pin "sortablejs", to: "https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/+esm"

2. Stimulus Controller Design

// app/javascript/controllers/dashboard_dnd_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"

export default class extends Controller {
  static values = { storageKey: { type: String, default: "dashboard-layout-v1" } }

  connect() {
    this._restoreOrder()
    this._restoreCollapsed()

    this._sortable = new Sortable(this.element, {
      handle: ".dnd-handle",  // only the grip icon triggers drag
      animation: 150,
      ghostClass: "dnd-ghost",
      chosenClass: "dnd-chosen",
      onEnd: () => this._saveOrder()
    })
  }

  disconnect() {
    this._sortable?.destroy()
  }

  toggle(event) {
    const card    = event.currentTarget.closest("[data-card-id]")
    const content = card?.querySelector(".card-collapsible")
    const icon    = card?.querySelector("[data-toggle-icon]")
    if (!content) return

    const collapsing = !content.classList.contains("collapsed")
    content.classList.toggle("collapsed", collapsing)
    icon?.classList.toggle("rotate-180", collapsing)
    this._saveCollapsed()
  }

  _saveOrder() {
    const order = Array.from(this.element.children)
      .map(el => el.dataset.cardId)
      .filter(Boolean)
    localStorage.setItem(this.storageKeyValue, JSON.stringify(order))
  }

  _restoreOrder() {
    try {
      const order = JSON.parse(localStorage.getItem(this.storageKeyValue) || "[]")
      order.forEach(id => {
        const el = this.element.querySelector(`:scope > [data-card-id="${id}"]`)
        if (el) this.element.appendChild(el)
      })
    } catch (_) {}
  }

  _saveCollapsed() {
    const collapsed = Array.from(
      this.element.querySelectorAll(".card-collapsible.collapsed")
    ).map(el => el.closest("[data-card-id]")?.dataset.cardId).filter(Boolean)
    localStorage.setItem(this.storageKeyValue + "-collapsed", JSON.stringify(collapsed))
  }

  _restoreCollapsed() {
    try {
      const collapsed = JSON.parse(
        localStorage.getItem(this.storageKeyValue + "-collapsed") || "[]"
      )
      collapsed.forEach(id => {
        const card = this.element.querySelector(`:scope > [data-card-id="${id}"]`)
        card?.querySelector(".card-collapsible")?.classList.add("collapsed")
        card?.querySelector("[data-toggle-icon]")?.classList.add("rotate-180")
      })
    } catch (_) {}
  }
}

Two design decisions worth noting

handle: ".dnd-handle" is essential. Without a handle, clicking buttons or scrolling inside a card competes with the drag gesture. One of the cards has a pinch-zoom/pan canvas — without an explicit handle this would be unusable.

_restoreOrder() runs before new Sortable(). Sort the DOM first, then initialize SortableJS on the already-sorted list. If you do it the other way around, SortableJS starts with the original (unsorted) state.


3. Card Collapse Animation — The Height Problem

Animating height to zero sounds trivial. It isn’t.

Why max-height is frustrating

The common approach:

.collapsible { max-height: 1000px; transition: max-height 0.3s ease; overflow: hidden; }
.collapsible.collapsed { max-height: 0; }

The problem: the animation duration covers the full max-height range, not the actual height. If the card is 200px tall but max-height is 1000px, collapsing “wastes” 800px worth of transition time doing nothing, then compresses 200px in the remaining fraction. It looks broken.

You can fix this with JS by measuring scrollHeight and setting it as the max-height. But when the card content is a Turbo Frame loaded lazily, scrollHeight at the time you need it might be 0 or just the skeleton height.

The grid-template-rows trick

.card-collapsible {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 0.25s ease;
}
.card-collapsible.collapsed {
  grid-template-rows: 0fr;
}
.card-collapsible > * {
  overflow: hidden;
  min-height: 0;  /* without this, 0fr doesn't actually reach 0 */
}

CSS Grid can transition fr values. Going from 1fr to 0fr collapses the content exactly to zero, no matter what the actual height is — no JS measurement needed.

Benefits:

  • No height measurement in JS
  • Works correctly even when content is loaded after the fact
  • Unlike display: none, the element stays in layout while collapsed

4. HTML Structure

<!-- Outer wrapper: data-card-id is what SortableJS tracks -->
<div data-card-id="scoreboard">

  <!-- Drag handle + collapse button -->
  <div class="dnd-handle flex items-center justify-between px-3 py-1.5
              bg-slate-50 border border-b-0 border-slate-200
              rounded-t-2xl cursor-grab active:cursor-grabbing select-none">
    <div class="flex items-center gap-2 text-slate-400">
      <!-- 6-dot grip icon -->
      <svg viewBox="0 0 10 16" fill="currentColor" class="h-3.5 w-3.5">
        <circle cx="2" cy="2" r="1.5"/><circle cx="8" cy="2" r="1.5"/>
        <circle cx="2" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/>
        <circle cx="2" cy="14" r="1.5"/><circle cx="8" cy="14" r="1.5"/>
      </svg>
      <span class="text-[10px] font-semibold uppercase tracking-[.24em]">My Matches</span>
    </div>
    <button data-action="click->dashboard-dnd#toggle">
      <svg data-toggle-icon class="h-3.5 w-3.5 transition-transform duration-200" ...>
        <polyline points="18 15 12 9 6 15"/>
      </svg>
    </button>
  </div>

  <!-- Collapsible content -->
  <div class="card-collapsible">
    <div><!-- inner wrapper required for the grid trick -->
      <section id="scoreboard-section">
        <%= turbo_frame_tag "scoreboard_frame", src: ..., loading: :lazy do %>
          <!-- skeleton -->
        <% end %>
      </section>
    </div>
  </div>

</div>

Visual join: the drag handle uses rounded-t-2xl border-b-0 and the card content uses rounded-b-2xl, so they read as a single connected card.


5. Compatibility with Turbo Frame Lazy Loading

Turbo Frame lazy loading triggers when the frame enters the viewport.

_restoreOrder() only moves DOM nodes — it doesn’t retrigger loading. At connect() time, frames probably haven’t loaded yet. Moving them with appendChild doesn’t cause a reload. After the DOM is sorted, frames load normally when they scroll into view.

Cards restored as collapsed are effectively outside the viewport, so their frames don’t load until the user expands them. This is an unintentional but useful side effect — it eliminates unnecessary API calls for sections the user never views.


6. Ghost Styles

SortableJS’s default ghost is just transparent. A bit of CSS makes it feel more polished:

.dnd-ghost {
  opacity: 0.35;
  border-radius: 1rem;
  background: #e2e8f0;
}
.dnd-chosen {
  box-shadow: 0 20px 40px -8px rgba(0, 0, 0, 0.18);
}

dnd-chosen is applied to the actual dragged element (not the ghost placeholder). The strong shadow gives it a “lifted” feel.


Result

  • All 3 cards reorderable via drag handle
  • Each card collapses/expands with a smooth animation via the chevron button
  • Order + collapsed state persisted to localStorage, restored on reload
  • No conflicts with Turbo Frame lazy loading
  • Works identically on mobile (touch) and desktop

The total addition: one library pin (SortableJS), one new Stimulus controller, a few lines of CSS.