Phoenix LiveView server-side rendering is very fast. However, there are situations where client-side implementation results in a better user experience.
In this tutorial, we'll add SortableJS to a Phoenix LiveView project to implement a drag and drop interaction. I'll show you how you can make client-side drag and drop event data available to a server-side Phoenix LiveView using hooks.
You can also grab the source code for this demo here if you want to follow along.
Create Phoenix LiveView project
Create a Phoenix LiveView project by running the following command in your terminal:
$ mix phx.new draggable_walkthru --live
NOTE → Learn more about Elixir Mix here.
You may need to update phoenix_live_view
and phoenix_live_dashboard
in mix.exs
:
{:phoenix_live_view, "~> 0.13.0"},
{:phoenix_live_dashboard, "~> 0.2.0"},
Remove boilerplate content from header
tag in root.html.leex
and replace it with an h1
tag:
<!DOCTYPE >
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "DraggableWalkthru", suffix: " · Phoenix Framework" %>
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body>
<header>
<div class="container">
<h1>Draggable</h1>
</div>
</header>
<%= @inner_content %>
</body>
</html>
Delete everything from page_live.html.leex
but do not delete the file.
When you run mix phx.server
you should see the following:

NOTE → Don't forget to run mix ecto.create
before you run mix phx.server
.
If you're following along and don't need an Ecto database, you can add --no-ecto
when creating your project (mix phx.new draggable_walkthru --live --no-ecto
).
If you add the --no-ecto
flag, you won't need to run mix ecto.create
.
Add Tailwind CSS and JavaScript dependencies
In this example, I'm using Tailwind CSS for quick utility-based styling. Because this is a simple project, I'm opting to use the CDN for Tailwind. Add the CDN link tag to root.html.leex
before the link tag for app.css
:
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
We'll use SortableJS to implement drag-and-drop. Add SortableJS to your project by running the following command in your terminal:
npm i sortablejs --prefix assets
Setup PageLive and create LiveComponent
In page_live.ex
, remove both handle_event
functions and the search
function. Since we removed the default content from page_live.html.leex
, we don't need these anymore. Remove the call to assign from the mount
function. Your page_live.ex
file should look like this:
defmodule DraggableWalkthruWeb.PageLive do
use DraggableWalkthruWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
end
Within mount we'll add some mock data. In a real app this data would come from a datasource.
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
{:ok, socket}
end
Next we'll add three assigns to socket
pool
- draggable items that haven't been assigned to a drop zonedrop_zone_a
- our first drop zonedrop_zone_b
- our second drop zone
In mount, all the elements in draggables
will be allocated to pool
and our drop zones will be initialized with empty lists:
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
socket =
socket
|> assign(:pool, draggables)
|> assign(:drop_zone_a, [])
|> assign(:drop_zone_b, [])
{:ok, socket}
end
Next we'll create a LiveComponent for our drop zones.
Within lib/draggable_walkthru_web/live
create a file named drop_zone_component.ex
.
Add a mount
function and a render
function. Mount will return {:ok, socket}
. Within render, we'll add Live Eex (the multiline string preceded by ~L
) to define how we want our component rendered.
defmodule DraggableWalkthruWeb.PageLive.DropZoneComponent do
use Phoenix.LiveComponent
@impl true
def mount(socket) do
{:ok, socket}
end
@impl true
def render(assigns) do
~L"""
<div class="dropzone grid gap-3 p-6 border-solid border-2 border-<%= @color %>-300 rounded-md my-6" id="<%= @drop_zone_id %>">
<%= @title %>
<%= for %{text: text, id: id} <- @draggables do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-<%= @color %>-700 text-white"><%= text %></div>
<% end %>
</div>
"""
end
end
NOTE → We've used Tailwind CSS classes to style the elements in our component render.
To learn more about these utility classes review the Tailwind CSS documentation!
In our drop zone LiveComponent we'll pass through four assigns
draggables
- the list of items currently in the drop zone. The list is iterated over and a div is rendered for each item.drop_zone_id
- a value for the root div'sid
attribute (we'll need this for drag-and-drop later)title
- text to display at the top of the drop zonecolor
- the background color for the drop zone and the items within it
NOTE → We add draggable="true"
to our item div to indicate the element can be dragged. Learn more about the HTML global attribute draggable
within the MDN Web Docs.
Now that we have a drop zone component, we can use it within page_live.html.leex
:
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_a,
drop_zone_id: "drop_zone_a",
title: "Drop Zone A",
color: "orange"
%>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_b,
drop_zone_id: "drop_zone_b",
title: "Drop Zone B",
color: "green"
%>
Now our app will have two empty drop zones with their own titles and colors.

NOTE → Learn more about Phoenix LiveComponents in the Phoenix LiveView documentation!
We'll add our pool directly inside page_live.html.leex
above our drop zones. We'll iterate over the pool
assign and render a draggable div for each item in the pool
list.
<div class="dropzone grid gap-3" id="pool">
<%= for %{text: text, id: id} <- @pool do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-blue-700 text-white"><%= text %></div>
<% end %>
</div>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_a,
drop_zone_id: "drop_zone_a",
title: "Drop Zone A",
color: "orange"
%>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_b,
drop_zone_id: "drop_zone_b",
title: "Drop Zone B",
color: "green"
%>
Now our app looks like this
We can drag the list items but we can't move them out of the pool and we can't rearrange them.

Add the Drag hook
Now that we have our markup in LiveEEx, we can add the client-side javascript to implement drag and drop functionality. We're adding this on the client-side because the user experience is smoother than implementing it server-side within our page_live.ex
.
First, create a file within assets/js
named dragHook.js
. We'll import sortablejs
at the top and add an object that is our default export:
import Sortable from 'sortablejs';
export default {};
Client hooks can implement a number of lifecycle callbacks. In this hook, we only need to implement mounted
. This callback is called when the element we add this hook to has been mounted to the DOM and the server-side LiveView has also mounted.
import Sortable from 'sortablejs';
export default {
mounted() {
/* implementation will go here */
}
};
NOTE → Read more about JavaScript interoperability in the Phoenix LiveView documentation.
At the beginning of our mounted
lifecycle callback function we'll define three variables:
dragged
- the item currently being draggedhook
- a reference tothis
that we'll use laterselector
- our element's id as a string prepended with "#"
export default {
mounted() {
let dragged; // this will change so we use `let`
const hook = this;
const selector = '#' + this.el.id;
}
}
Right away you'll see this
has an el
member. The callback lifecycle functions have several attributes in scope including el
, a reference to the DOM element the hook has been added to.
Before we move on
let's add the hook to our DOM element so we can test to see if the el
and it's ID are being passed through correctly.
First, we'll import our hook within app.js
and add it to our LiveSocket. We'll create a Hooks
object and add it to our SocketOptions within new LiveSocket()
.
// assets/js/app.js
// other imports omitted for clarity
import Drag from './dragHook';
const Hooks = { Drag: Drag }; // define an object to contain our hooks, our first key is `Drag`
let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute('content');
let liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks, // add hooks to the SocketOptions object
});
Next, within page_live.html.leex
, we'll surround our markup with a div
that has a phx-hook
attribute and an id
attribute. The value for phx-hook
should match the key we used in our Hooks
object and when using phx-hook
we have to define a unique DOM id as well.
<div phx-hook="Drag" id="drag"> <!-- Here is our div with phx-hook and id attrs -->
<div class="dropzone grid gap-3" id="pool">
<%= for %{text: text, id: id} <- @pool do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-blue-700 text-white"><%= text %></div>
<% end %>
</div>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_a,
drop_zone_id: "drop_zone_a",
title: "Drop Zone A",
color: "orange"
%>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_b,
drop_zone_id: "drop_zone_b",
title: "Drop Zone B",
color: "green"
%>
</div>
Now if we add a console.log
after we define selector
in mounted
, we should see our message logged in the console:
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
console.log('The selector is:', selector);
}

Great! We have a hook and it's linking our LiveEEx markup with our JavaScript.
Next, we'll start adding the drag and drop functionality.
Implement drag and drop using SortableJS
Within dragHook.js
we want to make the "pool", "drop zone A", and "drop zone B" sortable and we want to be able to drag items from one to any of the others. Within mounted, we'll start by getting references to all dropzone elements using document.querySelectorAll
and the "dropzone" class as the selector:
import Sortable from 'sortablejs';
export default {
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
// make sure you prepend the class's name with a period "." to indicate it's a class
document.querySelectorAll('.dropzone').forEach((dropzone) => {
/* implementation to make this sortable will go here */
});
},
};
NOTE → In our markup, we've already added the class "dropzone" to our pool and drop zones "A" and "B"!
We'll iterate over the elements returned by querySelectorAll
using forEach
.
With each dropzone element, we'll instantiate a Sortable
by calling new Sortable
and passing in the element dropzone
as the first parameter and an object of Sortable options as our second parameter.
import Sortable from 'sortablejs';
export default {
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
document.querySelectorAll('.dropzone').forEach((dropzone) => {
new Sortable(dropzone, {
animation: 0,
delay: 50,
delayOnTouchOnly: true,
group: 'shared',
draggable: '.draggable',
ghostClass: 'sortable-ghost'
});
});
},
};
A detailed review of all the available options is outside the scope of this article, but we'll briefly review what options we've utilized here:
animation
- the number of milliseconds to animatedelay
- the number of milliseconds to delaydelayOnTouchOnly
- we only want to delay if the interaction is via touch (not mouse or keyboard) so mobile users can scroll without grabbing draggable itemsgroup
- this is a string identifier that is shared by our pool and drop zones "A" and "B". It tells sortable that draggable items can be dragged from one to the others since they share the same string identifierdraggable
- the selector Sortable will use to identify which child elements are draggableghostClass
- the class that should be used to style the drop placeholder
Since the draggable
class is used as a selector only, we don't need to add CSS. We've added the ghostClass
of .sortable-ghost
to the bottom of assets/css/app.scss
:
.sortable-ghost {
opacity: 0.75; // make the drop placeholder slightly transparent
}
Now that we've added our Sortable
instantiation, we can drag the items from the pool into dropzones "A" and "B"!

The drag and drop user experience is working!
Unfortunately though, our Elixir app doesn't know when an element has been moved nor where it has been moved to. Let's add that next!
Push and handle hook events
When we drop a draggable element into a drop zone we want to make that information available to our PageLive module (page_live.ex
). In a real app, we might want to make a database update or publish the information using PubSub when an item is dropped into a drop zone.
Before we try to pass that information from our JavaScript implementation, let's add an event handler to the PageLive module:
defmodule DraggableWalkthruWeb.PageLive do
use DraggableWalkthruWeb, :live_view
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
socket =
socket
|> assign(:pool, draggables)
|> assign(:drop_zone_a, [])
|> assign(:drop_zone_b, [])
{:ok, socket}
end
@impl true
def handle_event("dropped", params, socket) do
# implementation will go here
{:noreply, socket}
end
end
Our handle_event
callback function accepts as it's params:
- an event - here we are identifying the event with the string "dropped"
- parameters - parameters passed with the event
- the socket
We'll push the event from the Drag hook. We'll add another option, onEnd
, to the Sortable options:
import Sortable from 'sortablejs';
export default {
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
document.querySelectorAll('.dropzone').forEach((dropzone) => {
new Sortable(dropzone, {
animation: 0,
delay: 50,
delayOnTouchOnly: true,
group: 'shared',
draggable: '.draggable',
ghostClass: 'sortable-ghost',
onEnd: function (evt) {
/* implementation goes here */
},
});
});
},
};
Within onEnd
, we'll call hook.pushEventTo
. We'll pass selector
in as the first parameter. For our second parameter we want make sure to pass the same string we used as the event parameter in handle_event
so we'll pass in "dropped". For our third parameter, we'll pass in a payload object of parameters for use by handle_event
.
onEnd: function (evt) {
hook.pushEventTo(selector, 'dropped', {
draggedId: evt.item.id, // id of the dragged item
dropzoneId: evt.to.id, // id of the drop zone where the drop occured
draggableIndex: evt.newDraggableIndex, // index where the item was dropped (relative to other items in the drop zone)
});
},
NOTE → In our example, we use pushEventTo
. This function pushes the event to the LiveView or LiveComponent specified by the selector. Hooks also have pushEvent
if the event should be pushed to the LiveView server instead.
The parameters from the payload object are available within the second parameter of the handle_event
callback function in page_live.ex
. We can destructure the second parameter of the function definition to access the payload object members:
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, socket) do
# implementation will go here
{:noreply, socket}
end
Now that we have the necessary data being passed from the client to the server, we can use it to update the pool
, drop_zone_a
, and drop_zone_b
lists in the socket assigns.
Update socket assigns on drop
First, we'll identify which drop zone the drop occurred in. We'll get the correct atom for the event parameter dropzoneId
. We search our drop zone atoms for one that matches the dropzoneId
. We want to prevent the user from creating additional atoms and from corrupting our assigns.
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: %{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}} = socket) do
drop_zone_atom =
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.find(fn zone_atom -> to_string(zone_atom) == drop_zone_id end)
if drop_zone_atom === nil do
throw "invalid drop_zone_id"
end
{:noreply, socket}
end
Next, we'll get the dragged item using the event parameter draggedId
. We'll add a private function called find_dragged
that accepts the socket
's assigns as its first parameter and the draggedId
as its second parameter.
The function will combine the pool
, drop_zone_a
, and drop_zone_b
lists and use Enum.find
to return the draggable with an id equal to draggedId
. In handle_event
we'll destructure assigns
from the third parameter, socket
.
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
{:noreply, socket}
end
defp find_dragged(%{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}, dragged_id) do
pool ++ drop_zone_a ++ drop_zone_b
|> Enum.find(nil, fn draggable ->
draggable.id == dragged_id
end)
end
Finally, we'll use drop_zone_atom
, dragged
, and the event parameter draggableIndex
to update the pool
, drop_zone_a
, and drop_zone_b
lists in socket
's assigns. We'll reduce over the atoms for the lists using Enum.reduce
. We'll pass in the socket as the initial value for the accumulator and in the callback function, we'll destructure assigns from the current accumulator.
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
socket =
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->
# implementation goes here
end)
{:noreply, socket}
end
Within the reduce callback function we'll update the list for the current atom. We'll write a private function update_list
with two function signature: one for use when the list being updated is the list being dropped into and a second function for when the list being updated isn't the list being dropped into.
Within update_list
we'll remove the dragged item by calling a private function remove_dragged
. Within the function when the list being updated is the list being dropped into, we'll add the dragged item back into the list using List.insert_at
and the draggable_index
parameter from the event.
defp update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom == drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
|> List.insert_at(draggable_index, dragged)
end
defp update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom != drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
end
defp remove_dragged(list, dragged_id) do
list
|> Enum.filter(fn draggable ->
draggable.id != dragged_id
end)
end
Once we've called update_list
, we'll call assign, passing in the accumulator
socket, the zone atom, and the updated list. We'll assign the return from Enum.reduce
to socket and return {:noreply, socket}
.
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
socket = # assign to socket
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->
updated_list =
assigns
|> update_list(zone_atom, dragged, drop_zone_atom, draggable_index)
accumulator
|> assign(zone_atom, updated_list)
end)
{:noreply, socket}
end
The PageLive module (page_live.ex
) will look like this when we're finished
defmodule DraggableWalkthruWeb.PageLive do
use DraggableWalkthruWeb, :live_view
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
socket =
socket
|> assign(:pool, draggables)
|> assign(:drop_zone_a, [])
|> assign(:drop_zone_b, [])
{:ok, socket}
end
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
socket =
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->
updated_list =
assigns
|> update_list(zone_atom, dragged, drop_zone_atom, draggable_index)
accumulator
|> assign(zone_atom, updated_list)
end)
{:noreply, socket}
end
defp get_drop_zone_atom(drop_zone_id) do
case drop_zone_id in ["pool", "drop_zone_a", "drop_zone_b"] do
true ->
drop_zone_id |> String.to_existing_atom()
false ->
throw "invalid drop_zone_id"
end
end
defp find_dragged(%{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}, dragged_id) do
pool ++ drop_zone_a ++ drop_zone_b
|> Enum.find(nil, fn draggable ->
draggable.id == dragged_id
end)
end
def update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom == drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
|> List.insert_at(draggable_index, dragged)
end
def update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom != drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
end
def remove_dragged(list, dragged_id) do
list
|> Enum.filter(fn draggable ->
draggable.id != dragged_id
end)
end
end
Now we can drag items into the various drop zones and we know the data is reaching the back end because the colors of the dropped items change!

The colors change because in our DropZoneComponent (drop_zone_component.ex
) we use the color assign passed in as the background color of the rendered items. When the assigns in the PageLive module (page_live.ex
) are updated, those updates flow into our DropZoneComponent and the list items are rendered with the color assign.
@impl true
def render(assigns) do
~L"""
<div class="dropzone grid gap-3 p-6 border-solid border-2 border-<%= @color %>-300 rounded-md my-6" id="<%= @drop_zone_id %>">
<%= @title %>
<%= for %{text: text, id: id} <- @draggables do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-<%= @color %>-700 text-white"><%= text %></div>
<% end %>
</div>
"""
end
Next steps
To learn more, check out the resources listed below.
Source code
You can grab the source code for this demo here.