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:
