Saturday, August 30, 2025

Drag-and-Drop Photo Reordering: Modern UX for Property Images

Paul (Founder)
UX & Features
Two developers collaborating on a coding project

On 30 August 2025, we implemented drag-and-drop photo reordering using Sortable.js. This feature allows agencies to control which property photos appear first in listings by simply dragging thumbnails into the desired order—no forms, no dropdowns, just intuitive direct manipulation. It's the kind of polished interaction that makes software feel modern and thoughtful.

For letting agencies, photo order matters enormously. The first image is what appears in portal feeds, email alerts, and social media posts. It's often the deciding factor in whether a prospective tenant clicks through to view details. Being able to reorder photos quickly—during the listing creation workflow or when refreshing stale listings—saves time and improves marketing effectiveness.

What Your Team Will Notice

In the property photo management modal, thumbnails now have a subtle grab cursor on hover. Click and drag a photo, and it moves fluidly, with other photos flowing around it to make space. Release, and the new order saves automatically via AJAX—no page refresh, no "Save Changes" button to remember clicking. The interface provides immediate visual feedback: dragged photos have a slight shadow and opacity change, making it clear what's being moved.

On mobile devices (tablets especially), the interaction adapts gracefully. Touch-and-hold initiates drag, haptic feedback (on supported devices) confirms the action, and photos reorder as your finger moves. The implementation respects platform conventions: what feels natural on desktop works equally well on touch devices.

The system also handles edge cases thoughtfully. If the network fails mid-drag, the order reverts to its previous state with a discreet error notification. If multiple users edit the same property simultaneously, Action Cable pushes updates in real-time, preventing conflicting changes. These details separate polished software from prototypes.

Under the Bonnet: Sortable.js Integration

Sortable.js is a lightweight (11KB gzipped), dependency-free JavaScript library for drag-and-drop list reordering. We added it via npm:

npm install sortablejs

The Stimulus controller wraps Sortable.js with Rails conventions:

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

export default class extends Controller {
  static values = {
    url: String,
    propertyId: Number
  }

  connect() {
    this.sortable = Sortable.create(this.element, {
      animation: 150,
      ghostClass: "sortable-ghost",
      handle: ".drag-handle",
      onEnd: this.handleSortEnd.bind(this)
    })
  }

  async handleSortEnd(event) {
    const photoIds = Array.from(this.element.children).map(el => el.dataset.photoId)

    try {
      const response = await fetch(this.urlValue, {
        method: "PATCH",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
        },
        body: JSON.stringify({ photo_ids: photoIds })
      })

      if (!response.ok) {
        throw new Error("Failed to update photo order")
      }

      // Show success notification
      this.dispatch("sorted", { detail: { propertyId: this.propertyIdValue } })
    } catch (error) {
      console.error("Photo reordering error:", error)
      // Revert to original order
      this.sortable.sort(event.oldIndicies.map(i => this.element.children[i]))
      alert("Failed to save photo order. Please try again.")
    }
  }

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

Configuration options explained:

animation: 150: Photos smoothly animate to their new positions over 150ms—slow enough to be clear, fast enough to feel responsive.

ghostClass: The dragged photo gets this CSS class, allowing visual customization (we add opacity and shadow).

handle: Only the drag handle icon triggers dragging—prevents accidental reorders when clicking photos to preview.

onEnd callback: Fires when drag completes, sending the new order to the server.

The HTML structure uses data attributes:

<!-- app/views/shared/_photo_manage_modal.html.erb -->
<div data-controller="photo-sortable"
     data-photo-sortable-url-value="<%= reorder_property_photos_path(property) %>"
     data-photo-sortable-property-id-value="<%= property.id %>">

  <% property.property_photos.each do |photo| %>
    <div class="photo-thumbnail" data-photo-id="<%= photo.id %>">
      <%= image_tag photo.image.variant(resize_to_limit: [200, 150]) %>
      <div class="drag-handle">⋮⋮</div>
    </div>
  <% end %>
</div>

Each thumbnail has a data-photo-id attribute. After dragging, JavaScript extracts these IDs in their new order and sends to the server: [42, 15, 23, 8, ...].

Server-Side: Batch Position Updates

The controller endpoint receives the ordered photo IDs and updates positions in a single database transaction:

# app/controllers/properties_controller.rb
def reorder_photos
  @property = Property.find(params[:id])
  photo_ids = params[:photo_ids]

  ActiveRecord::Base.transaction do
    photo_ids.each_with_index do |photo_id, index|
      photo = @property.property_photos.find(photo_id)
      photo.update_column(:position, index + 1)
    end
  end

  head :no_content
rescue ActiveRecord::RecordNotFound
    head :not_found
end

Using update_column bypasses validations and callbacks—we're only changing position, so full save overhead is unnecessary. This optimization matters when reordering 20+ photos.

The transaction ensures atomicity: either all positions update or none do. If the server crashes mid-update, the database rolls back, preventing inconsistent state.

The route definition:

# config/routes.rb
resources :properties do
  member do
    patch :reorder_photos
  end
end

This generates PATCH /properties/:id/reorder_photos, a RESTful endpoint for the specific operation.

CSS for Visual Feedback

Custom CSS enhances the drag experience:

/* app/assets/stylesheets/tailadmin.css */
.photo-thumbnail {
  position: relative;
  transition: transform 0.15s ease;
}

.photo-thumbnail:hover {
  transform: translateY(-2px);
  cursor: grab;
}

.photo-thumbnail:active {
  cursor: grabbing;
}

.sortable-ghost {
  opacity: 0.4;
  background: #f3f4f6;
}

.drag-handle {
  position: absolute;
  top: 8px;
  right: 8px;
  padding: 4px 8px;
  background: rgba(0, 0, 0, 0.6);
  color: white;
  border-radius: 4px;
  cursor: grab;
  user-select: none;
}

.drag-handle:active {
  cursor: grabbing;
}

The hover lift (translateY(-2px)) provides tactile feedback—photos feel "grabbable." The ghost class (applied during drag) makes the original position semi-transparent, clearly showing where the photo will land.

Performance Considerations

Initially, our implementation updated photo positions one at a time (N queries for N photos). Profiling revealed this was slow for properties with 20+ photos. The optimization:

# BEFORE: N queries
photo_ids.each_with_index do |photo_id, index|
  PropertyPhoto.where(id: photo_id).update_all(position: index + 1)
end

# AFTER: 1 query using Arel
updates = photo_ids.each_with_index.map do |photo_id, index|
  "WHEN #{photo_id} THEN #{index + 1}"
end.join(" ")

PropertyPhoto.where(id: photo_ids).update_all(
  "position = CASE id #{updates} END"
)

The optimized version uses SQL's CASE statement to update all positions in a single query. For 20 photos, this reduces execution time from ~200ms to ~15ms.

Testing Drag-and-Drop

Testing JavaScript interactions requires system specs with a real browser:

# spec/system/photo_reordering_spec.rb
RSpec.describe "Photo Reordering", type: :system, js: true do
  let(:property) { create(:property, :with_photos, photo_count: 5) }

  before do
    sign_in property.agency.users.first
    visit property_path(property)
    click_button "Manage Photos"
  end

  it "reorders photos via drag and drop" do
    photos = property.property_photos.order(:position)
    first_photo = photos.first
    last_photo = photos.last

    # Simulate drag from first position to last
    drag_element = find("[data-photo-id='#{first_photo.id}'] .drag-handle")
    drop_target = find("[data-photo-id='#{last_photo.id}']")

    drag_element.drag_to(drop_target)

    # Wait for AJAX request to complete
    expect(page).to have_css(".photo-thumbnail", count: 5)

    # Verify order changed in database
    property.reload
    expect(property.property_photos.first).not_to eq(first_photo)
    expect(property.property_photos.last).to eq(first_photo)
  end
end

System specs use Capybara with a headless Chrome driver, simulating real user interactions. The js: true flag enables JavaScript execution.

Mobile and Touch Support

Sortable.js handles touch events automatically, but we added mobile-specific enhancements:

// Detect touch devices
const isTouchDevice = 'ontouchstart' in window

if (isTouchDevice) {
  this.sortable = Sortable.create(this.element, {
    animation: 150,
    delay: 200,  // Delay before drag starts (prevents accidental drags during scrolling)
    delayOnTouchOnly: true,
    touchStartThreshold: 5  // Pixels of movement before drag initiates
  })
}

The delay: 200 means touch-and-hold for 200ms before drag starts. This prevents accidental drags when scrolling through photos—a common frustration on touch devices.

What's Next

Future enhancements could include:

  • Bulk operations (select multiple photos, reorder as group)
  • Undo/redo for order changes
  • Keyboard navigation (arrow keys to reorder, for accessibility)
  • Automatic AI-based suggestions ("This living room photo might work better first")

But the core interaction—intuitive, fast drag-and-drop reordering—was established on 30 August, significantly improving the photo management workflow.


Related articles: