Patterns Medium
Reorder List
Draggable list where items can be reordered by dragging. Items animate to new positions smoothly using the FLIP technique for fluid layout transitions.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.demo {
width: min(440px, 100%);
display: flex;
flex-direction: column;
gap: 1rem;
}
.demo-title {
font-size: 1.25rem;
font-weight: 700;
color: #f4f4f5;
}
.demo-subtitle {
font-size: 0.8rem;
color: #52525b;
margin-top: 0.25rem;
}
/* ── Reorder list ── */
.reorder-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.4rem;
position: relative;
}
.reorder-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
user-select: none;
will-change: transform;
position: relative;
z-index: 1;
}
.reorder-item.placeholder {
opacity: 0.3;
border-style: dashed;
border-color: rgba(109, 40, 217, 0.3);
}
.reorder-handle {
color: #3f3f46;
font-size: 1.1rem;
cursor: grab;
line-height: 1;
transition: color 0.15s;
touch-action: none;
display: grid;
place-items: center;
width: 24px;
height: 24px;
}
.reorder-handle:hover {
color: #71717a;
}
.reorder-handle:active {
cursor: grabbing;
}
.reorder-icon {
width: 32px;
height: 32px;
border-radius: 0.5rem;
display: grid;
place-items: center;
font-size: 0.9rem;
flex-shrink: 0;
}
.reorder-text {
flex: 1;
font-size: 0.85rem;
font-weight: 500;
color: #d4d4d8;
}
.reorder-badge {
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #71717a;
letter-spacing: 0.04em;
}
.reorder-index {
font-size: 0.65rem;
font-weight: 700;
color: #3f3f46;
min-width: 18px;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* ── Floating clone ── */
.reorder-clone {
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.92;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(109, 40, 217, 0.3);
border-radius: 0.75rem;
transform: scale(1.03);
}(function () {
"use strict";
const list = document.getElementById("reorder-list");
let draggedItem = null;
let clone = null;
let startY = 0;
// ── Capture rects for FLIP ──
function captureRects() {
const rects = {};
list.querySelectorAll(".reorder-item").forEach((el) => {
rects[el.dataset.id] = el.getBoundingClientRect();
});
return rects;
}
// ── FLIP animate all items ──
function flipAnimate(firstRects) {
list.querySelectorAll(".reorder-item").forEach((el) => {
const id = el.dataset.id;
const first = firstRects[id];
if (!first) return;
const last = el.getBoundingClientRect();
const dy = first.top - last.top;
if (dy === 0) return;
el.style.transform = `translateY(${dy}px)`;
el.style.transition = "none";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.3s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
el.style.transform = "";
},
{ once: true }
);
});
});
});
}
// ── Update index numbers ──
function updateIndices() {
list.querySelectorAll(".reorder-item").forEach((el, i) => {
const idx = el.querySelector(".reorder-index");
if (idx) idx.textContent = String(i + 1);
});
}
// ── Pointer down on handle ──
list.addEventListener("pointerdown", (e) => {
const handle = e.target.closest(".reorder-handle");
if (!handle) return;
const item = handle.closest(".reorder-item");
if (!item) return;
e.preventDefault();
handle.setPointerCapture(e.pointerId);
draggedItem = item;
startY = e.clientY;
// Create floating clone
const rect = item.getBoundingClientRect();
clone = item.cloneNode(true);
clone.className = "reorder-item reorder-clone";
clone.style.width = rect.width + "px";
clone.style.left = rect.left + "px";
clone.style.top = rect.top + "px";
clone.style.background = "rgba(255,255,255,0.06)";
document.body.appendChild(clone);
// Mark original as placeholder
item.classList.add("placeholder");
});
// ── Pointer move ──
window.addEventListener("pointermove", (e) => {
if (!draggedItem || !clone) return;
const dy = e.clientY - startY;
clone.style.transform = `translateY(${dy}px) scale(1.03)`;
// Determine swap target
const items = Array.from(list.querySelectorAll(".reorder-item"));
const dragIndex = items.indexOf(draggedItem);
for (let i = 0; i < items.length; i++) {
if (i === dragIndex) continue;
const rect = items[i].getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (i < dragIndex && e.clientY < midY) {
// Move up
const firstRects = captureRects();
list.insertBefore(draggedItem, items[i]);
updateIndices();
flipAnimate(firstRects);
break;
} else if (i > dragIndex && e.clientY > midY) {
// Move down
const firstRects = captureRects();
list.insertBefore(draggedItem, items[i].nextSibling);
updateIndices();
flipAnimate(firstRects);
break;
}
}
});
// ── Pointer up ──
window.addEventListener("pointerup", () => {
if (!draggedItem) return;
draggedItem.classList.remove("placeholder");
if (clone) {
clone.remove();
clone = null;
}
draggedItem = null;
updateIndices();
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reorder List</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div>
<h2 class="demo-title">Reorder List</h2>
<p class="demo-subtitle">Drag items to reorder — FLIP animation keeps it smooth</p>
</div>
<ul class="reorder-list" id="reorder-list">
<li class="reorder-item" data-id="1">
<span class="reorder-index">1</span>
<span class="reorder-handle" aria-hidden="true">⠿</span>
<div class="reorder-icon" style="background:rgba(168,85,247,0.2);border:1px solid rgba(168,85,247,0.4)">🎨</div>
<span class="reorder-text">Design System</span>
<span class="reorder-badge">UI</span>
</li>
<li class="reorder-item" data-id="2">
<span class="reorder-index">2</span>
<span class="reorder-handle" aria-hidden="true">⠿</span>
<div class="reorder-icon" style="background:rgba(59,130,246,0.2);border:1px solid rgba(59,130,246,0.4)">⚙️</div>
<span class="reorder-text">API Integration</span>
<span class="reorder-badge">DEV</span>
</li>
<li class="reorder-item" data-id="3">
<span class="reorder-index">3</span>
<span class="reorder-handle" aria-hidden="true">⠿</span>
<div class="reorder-icon" style="background:rgba(16,185,129,0.2);border:1px solid rgba(16,185,129,0.4)">📊</div>
<span class="reorder-text">Analytics Dashboard</span>
<span class="reorder-badge">DATA</span>
</li>
<li class="reorder-item" data-id="4">
<span class="reorder-index">4</span>
<span class="reorder-handle" aria-hidden="true">⠿</span>
<div class="reorder-icon" style="background:rgba(245,158,11,0.2);border:1px solid rgba(245,158,11,0.4)">🔒</div>
<span class="reorder-text">Auth & Permissions</span>
<span class="reorder-badge">SEC</span>
</li>
<li class="reorder-item" data-id="5">
<span class="reorder-index">5</span>
<span class="reorder-handle" aria-hidden="true">⠿</span>
<div class="reorder-icon" style="background:rgba(239,68,68,0.2);border:1px solid rgba(239,68,68,0.4)">🚀</div>
<span class="reorder-text">CI/CD Pipeline</span>
<span class="reorder-badge">OPS</span>
</li>
<li class="reorder-item" data-id="6">
<span class="reorder-index">6</span>
<span class="reorder-handle" aria-hidden="true">⠿</span>
<div class="reorder-icon" style="background:rgba(236,72,153,0.2);border:1px solid rgba(236,72,153,0.4)">📝</div>
<span class="reorder-text">Documentation</span>
<span class="reorder-badge">DOCS</span>
</li>
</ul>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useCallback, useLayoutEffect } from "react";
interface ListItem {
id: number;
emoji: string;
text: string;
badge: string;
bg: string;
border: string;
}
const initialItems: ListItem[] = [
{
id: 1,
emoji: "🎨",
text: "Design System",
badge: "UI",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 2,
emoji: "⚙️",
text: "API Integration",
badge: "DEV",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
{
id: 3,
emoji: "📊",
text: "Analytics Dashboard",
badge: "DATA",
bg: "rgba(16,185,129,0.2)",
border: "rgba(16,185,129,0.4)",
},
{
id: 4,
emoji: "🔒",
text: "Auth & Permissions",
badge: "SEC",
bg: "rgba(245,158,11,0.2)",
border: "rgba(245,158,11,0.4)",
},
{
id: 5,
emoji: "🚀",
text: "CI/CD Pipeline",
badge: "OPS",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 6,
emoji: "📝",
text: "Documentation",
badge: "DOCS",
bg: "rgba(236,72,153,0.2)",
border: "rgba(236,72,153,0.4)",
},
];
export default function ReorderList() {
const [items, setItems] = useState(initialItems);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const listRef = useRef<HTMLUListElement>(null);
const rectsRef = useRef<Record<number, DOMRect>>({});
const startYRef = useRef(0);
const cloneRef = useRef<HTMLDivElement | null>(null);
// Capture rects before state change
const captureRects = useCallback(() => {
if (!listRef.current) return;
const rects: Record<number, DOMRect> = {};
listRef.current.querySelectorAll<HTMLElement>("[data-id]").forEach((el) => {
rects[Number(el.dataset.id)] = el.getBoundingClientRect();
});
rectsRef.current = rects;
}, []);
// FLIP after render
useLayoutEffect(() => {
if (!listRef.current) return;
const firstRects = rectsRef.current;
listRef.current.querySelectorAll<HTMLElement>("[data-id]").forEach((el) => {
const id = Number(el.dataset.id);
const first = firstRects[id];
if (!first) return;
const last = el.getBoundingClientRect();
const dy = first.top - last.top;
if (dy === 0) return;
el.style.transform = `translateY(${dy}px)`;
el.style.transition = "none";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.3s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
el.style.transform = "";
},
{ once: true }
);
});
});
});
}, [items]);
const onPointerDown = useCallback(
(e: React.PointerEvent, index: number) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragIndex(index);
startYRef.current = e.clientY;
// Create clone
const itemEl = (e.target as HTMLElement).closest("[data-id]") as HTMLElement;
if (itemEl) {
const rect = itemEl.getBoundingClientRect();
const clone = document.createElement("div");
clone.innerHTML = itemEl.outerHTML;
clone.style.position = "fixed";
clone.style.left = rect.left + "px";
clone.style.top = rect.top + "px";
clone.style.width = rect.width + "px";
clone.style.pointerEvents = "none";
clone.style.zIndex = "9999";
clone.style.opacity = "0.92";
clone.style.boxShadow = "0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(109,40,217,0.3)";
clone.style.borderRadius = "0.75rem";
clone.style.transform = "scale(1.03)";
document.body.appendChild(clone);
cloneRef.current = clone;
}
const onMove = (ev: PointerEvent) => {
if (cloneRef.current) {
const dy = ev.clientY - startYRef.current;
cloneRef.current.style.transform = `translateY(${dy}px) scale(1.03)`;
}
// Check swap
if (!listRef.current) return;
const els = Array.from(listRef.current.querySelectorAll<HTMLElement>("[data-id]"));
setItems((prev) => {
const currentIndex = prev.findIndex((_, i) => i === index);
// We need to find which item is currently at the dragged position
let dragIdx = -1;
for (let i = 0; i < els.length; i++) {
if (Number(els[i].dataset.id) === prev[index]?.id) {
dragIdx = i;
break;
}
}
for (let i = 0; i < els.length; i++) {
if (i === dragIdx) continue;
const rect = els[i].getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if ((i < dragIdx && ev.clientY < midY) || (i > dragIdx && ev.clientY > midY)) {
captureRects();
const newItems = [...prev];
const [moved] = newItems.splice(dragIdx, 1);
newItems.splice(i, 0, moved);
index = i; // Update tracked index
return newItems;
}
}
return prev;
});
};
const onUp = () => {
setDragIndex(null);
if (cloneRef.current) {
cloneRef.current.remove();
cloneRef.current = null;
}
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
},
[captureRects]
);
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "grid",
placeItems: "center",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#e4e4e7",
}}
>
<div
style={{ width: "min(440px, 100%)", display: "flex", flexDirection: "column", gap: "1rem" }}
>
<div>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, color: "#f4f4f5" }}>Reorder List</h2>
<p style={{ fontSize: "0.8rem", color: "#52525b", marginTop: "0.25rem" }}>
Drag items to reorder — FLIP animation keeps it smooth
</p>
</div>
<ul
ref={listRef}
style={{
listStyle: "none",
display: "flex",
flexDirection: "column",
gap: "0.4rem",
position: "relative",
}}
>
{items.map((item, i) => (
<li
key={item.id}
data-id={item.id}
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.75rem 1rem",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: "0.75rem",
userSelect: "none",
willChange: "transform",
position: "relative",
zIndex: 1,
opacity: dragIndex === i ? 0.3 : 1,
borderStyle: dragIndex === i ? "dashed" : "solid",
borderColor: dragIndex === i ? "rgba(109,40,217,0.3)" : "rgba(255,255,255,0.08)",
}}
>
<span
style={{
fontSize: "0.65rem",
fontWeight: 700,
color: "#3f3f46",
minWidth: 18,
textAlign: "center",
fontVariantNumeric: "tabular-nums",
}}
>
{i + 1}
</span>
<span
onPointerDown={(e) => onPointerDown(e, i)}
style={{
color: "#3f3f46",
fontSize: "1.1rem",
cursor: "grab",
lineHeight: 1,
touchAction: "none",
display: "grid",
placeItems: "center",
width: 24,
height: 24,
}}
>
⠿
</span>
<div
style={{
width: 32,
height: 32,
borderRadius: "0.5rem",
display: "grid",
placeItems: "center",
fontSize: "0.9rem",
flexShrink: 0,
background: item.bg,
border: `1px solid ${item.border}`,
}}
>
{item.emoji}
</div>
<span style={{ flex: 1, fontSize: "0.85rem", fontWeight: 500, color: "#d4d4d8" }}>
{item.text}
</span>
<span
style={{
fontSize: "0.65rem",
fontWeight: 700,
padding: "0.15rem 0.5rem",
borderRadius: 999,
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.08)",
color: "#71717a",
letterSpacing: "0.04em",
}}
>
{item.badge}
</span>
</li>
))}
</ul>
</div>
</div>
);
}<script setup>
import { ref, nextTick } from "vue";
const initialItems = [
{
id: 1,
emoji: "\u{1F3A8}",
text: "Design System",
badge: "UI",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 2,
emoji: "\u2699\uFE0F",
text: "API Integration",
badge: "DEV",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
{
id: 3,
emoji: "\u{1F4CA}",
text: "Analytics Dashboard",
badge: "DATA",
bg: "rgba(16,185,129,0.2)",
border: "rgba(16,185,129,0.4)",
},
{
id: 4,
emoji: "\u{1F512}",
text: "Auth & Permissions",
badge: "SEC",
bg: "rgba(245,158,11,0.2)",
border: "rgba(245,158,11,0.4)",
},
{
id: 5,
emoji: "\u{1F680}",
text: "CI/CD Pipeline",
badge: "OPS",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 6,
emoji: "\u{1F4DD}",
text: "Documentation",
badge: "DOCS",
bg: "rgba(236,72,153,0.2)",
border: "rgba(236,72,153,0.4)",
},
];
const items = ref([...initialItems]);
const dragIndex = ref(null);
const listEl = ref(null);
let startY = 0;
let cloneEl = null;
async function animateFlip(oldRects) {
await nextTick();
if (!listEl.value) return;
listEl.value.querySelectorAll("[data-id]").forEach((el) => {
const id = Number(el.dataset.id);
const first = oldRects[id];
if (!first) return;
const last = el.getBoundingClientRect();
const dy = first.top - last.top;
if (dy === 0) return;
el.style.transform = `translateY(${dy}px)`;
el.style.transition = "none";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.3s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
el.style.transform = "";
},
{ once: true }
);
});
});
});
}
function onPointerDown(e, index) {
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
dragIndex.value = index;
startY = e.clientY;
const itemEl = e.target.closest("[data-id]");
if (itemEl) {
const rect = itemEl.getBoundingClientRect();
const clone = document.createElement("div");
clone.innerHTML = itemEl.outerHTML;
clone.style.position = "fixed";
clone.style.left = rect.left + "px";
clone.style.top = rect.top + "px";
clone.style.width = rect.width + "px";
clone.style.pointerEvents = "none";
clone.style.zIndex = "9999";
clone.style.opacity = "0.92";
clone.style.boxShadow = "0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(109,40,217,0.3)";
clone.style.borderRadius = "0.75rem";
clone.style.transform = "scale(1.03)";
document.body.appendChild(clone);
cloneEl = clone;
}
let trackedIndex = index;
const onMove = (ev) => {
if (cloneEl) {
const dy = ev.clientY - startY;
cloneEl.style.transform = `translateY(${dy}px) scale(1.03)`;
}
if (!listEl.value) return;
const els = Array.from(listEl.value.querySelectorAll("[data-id]"));
let dragIdx = -1;
for (let i = 0; i < els.length; i++) {
if (Number(els[i].dataset.id) === items.value[trackedIndex]?.id) {
dragIdx = i;
break;
}
}
for (let i = 0; i < els.length; i++) {
if (i === dragIdx) continue;
const rect = els[i].getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if ((i < dragIdx && ev.clientY < midY) || (i > dragIdx && ev.clientY > midY)) {
const oldRects = {};
els.forEach((el) => {
oldRects[Number(el.dataset.id)] = el.getBoundingClientRect();
});
const newItems = [...items.value];
const [moved] = newItems.splice(dragIdx, 1);
newItems.splice(i, 0, moved);
trackedIndex = i;
items.value = newItems;
animateFlip(oldRects);
break;
}
}
};
const onUp = () => {
dragIndex.value = null;
if (cloneEl) {
cloneEl.remove();
cloneEl = null;
}
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
}
function itemStyle(i) {
const isDragging = dragIndex.value === i;
return {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.75rem 1rem",
background: "rgba(255,255,255,0.04)",
border: `1px ${isDragging ? "dashed" : "solid"} ${isDragging ? "rgba(109,40,217,0.3)" : "rgba(255,255,255,0.08)"}`,
borderRadius: "0.75rem",
userSelect: "none",
willChange: "transform",
position: "relative",
zIndex: 1,
opacity: isDragging ? 0.3 : 1,
};
}
</script>
<template>
<div style="min-height: 100vh; background: #0a0a0a; display: grid; place-items: center; padding: 2rem; font-family: system-ui, -apple-system, sans-serif; color: #e4e4e7;">
<div style="width: min(440px, 100%); display: flex; flex-direction: column; gap: 1rem;">
<div>
<h2 style="font-size: 1.25rem; font-weight: 700; color: #f4f4f5;">Reorder List</h2>
<p style="font-size: 0.8rem; color: #52525b; margin-top: 0.25rem;">
Drag items to reorder — FLIP animation keeps it smooth
</p>
</div>
<ul ref="listEl" style="list-style: none; display: flex; flex-direction: column; gap: 0.4rem; position: relative; padding: 0; margin: 0;">
<li
v-for="(item, i) in items"
:key="item.id"
:data-id="item.id"
:style="itemStyle(i)"
>
<span style="font-size: 0.65rem; font-weight: 700; color: #3f3f46; min-width: 18px; text-align: center; font-variant-numeric: tabular-nums;">
{{ i + 1 }}
</span>
<span
@pointerdown="(e) => onPointerDown(e, i)"
style="color: #3f3f46; font-size: 1.1rem; cursor: grab; line-height: 1; touch-action: none; display: grid; place-items: center; width: 24px; height: 24px;"
>
⠿
</span>
<div :style="{ width: '32px', height: '32px', borderRadius: '0.5rem', display: 'grid', placeItems: 'center', fontSize: '0.9rem', flexShrink: 0, background: item.bg, border: '1px solid ' + item.border }">
{{ item.emoji }}
</div>
<span style="flex: 1; font-size: 0.85rem; font-weight: 500; color: #d4d4d8;">
{{ item.text }}
</span>
<span style="font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); color: #71717a; letter-spacing: 0.04em;">
{{ item.badge }}
</span>
</li>
</ul>
</div>
</div>
</template><script>
import { tick } from "svelte";
const initialItems = [
{
id: 1,
emoji: "\u{1F3A8}",
text: "Design System",
badge: "UI",
bg: "rgba(168,85,247,0.2)",
border: "rgba(168,85,247,0.4)",
},
{
id: 2,
emoji: "\u2699\uFE0F",
text: "API Integration",
badge: "DEV",
bg: "rgba(59,130,246,0.2)",
border: "rgba(59,130,246,0.4)",
},
{
id: 3,
emoji: "\u{1F4CA}",
text: "Analytics Dashboard",
badge: "DATA",
bg: "rgba(16,185,129,0.2)",
border: "rgba(16,185,129,0.4)",
},
{
id: 4,
emoji: "\u{1F512}",
text: "Auth & Permissions",
badge: "SEC",
bg: "rgba(245,158,11,0.2)",
border: "rgba(245,158,11,0.4)",
},
{
id: 5,
emoji: "\u{1F680}",
text: "CI/CD Pipeline",
badge: "OPS",
bg: "rgba(239,68,68,0.2)",
border: "rgba(239,68,68,0.4)",
},
{
id: 6,
emoji: "\u{1F4DD}",
text: "Documentation",
badge: "DOCS",
bg: "rgba(236,72,153,0.2)",
border: "rgba(236,72,153,0.4)",
},
];
let items = [...initialItems];
let dragIndex = null;
let listEl;
let startY = 0;
let cloneEl = null;
async function animateFlip(oldRects) {
await tick();
if (!listEl) return;
listEl.querySelectorAll("[data-id]").forEach((el) => {
const id = Number(el.dataset.id);
const first = oldRects[id];
if (!first) return;
const last = el.getBoundingClientRect();
const dy = first.top - last.top;
if (dy === 0) return;
el.style.transform = `translateY(${dy}px)`;
el.style.transition = "none";
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transform = "";
el.style.transition = "transform 0.3s cubic-bezier(0.22, 1, 0.36, 1)";
el.addEventListener(
"transitionend",
() => {
el.style.transition = "";
el.style.transform = "";
},
{ once: true }
);
});
});
});
}
function onPointerDown(e, index) {
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
dragIndex = index;
startY = e.clientY;
const itemEl = e.target.closest("[data-id]");
if (itemEl) {
const rect = itemEl.getBoundingClientRect();
const clone = document.createElement("div");
clone.innerHTML = itemEl.outerHTML;
clone.style.position = "fixed";
clone.style.left = rect.left + "px";
clone.style.top = rect.top + "px";
clone.style.width = rect.width + "px";
clone.style.pointerEvents = "none";
clone.style.zIndex = "9999";
clone.style.opacity = "0.92";
clone.style.boxShadow = "0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(109,40,217,0.3)";
clone.style.borderRadius = "0.75rem";
clone.style.transform = "scale(1.03)";
document.body.appendChild(clone);
cloneEl = clone;
}
let trackedIndex = index;
const onMove = (ev) => {
if (cloneEl) {
const dy = ev.clientY - startY;
cloneEl.style.transform = `translateY(${dy}px) scale(1.03)`;
}
if (!listEl) return;
const els = Array.from(listEl.querySelectorAll("[data-id]"));
let dragIdx = -1;
for (let i = 0; i < els.length; i++) {
if (Number(els[i].dataset.id) === items[trackedIndex]?.id) {
dragIdx = i;
break;
}
}
for (let i = 0; i < els.length; i++) {
if (i === dragIdx) continue;
const rect = els[i].getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if ((i < dragIdx && ev.clientY < midY) || (i > dragIdx && ev.clientY > midY)) {
const oldRects = {};
els.forEach((el) => {
oldRects[Number(el.dataset.id)] = el.getBoundingClientRect();
});
const newItems = [...items];
const [moved] = newItems.splice(dragIdx, 1);
newItems.splice(i, 0, moved);
trackedIndex = i;
items = newItems;
animateFlip(oldRects);
break;
}
}
};
const onUp = () => {
dragIndex = null;
if (cloneEl) {
cloneEl.remove();
cloneEl = null;
}
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
}
</script>
<div style="min-height: 100vh; background: #0a0a0a; display: grid; place-items: center; padding: 2rem; font-family: system-ui, -apple-system, sans-serif; color: #e4e4e7;">
<div style="width: min(440px, 100%); display: flex; flex-direction: column; gap: 1rem;">
<div>
<h2 style="font-size: 1.25rem; font-weight: 700; color: #f4f4f5;">Reorder List</h2>
<p style="font-size: 0.8rem; color: #52525b; margin-top: 0.25rem;">
Drag items to reorder — FLIP animation keeps it smooth
</p>
</div>
<ul bind:this={listEl} style="list-style: none; display: flex; flex-direction: column; gap: 0.4rem; position: relative; padding: 0; margin: 0;">
{#each items as item, i (item.id)}
<li
data-id={item.id}
style="display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; background: rgba(255,255,255,0.04); border: 1px {dragIndex === i ? 'dashed' : 'solid'} {dragIndex === i ? 'rgba(109,40,217,0.3)' : 'rgba(255,255,255,0.08)'}; border-radius: 0.75rem; user-select: none; will-change: transform; position: relative; z-index: 1; opacity: {dragIndex === i ? 0.3 : 1};"
>
<span style="font-size: 0.65rem; font-weight: 700; color: #3f3f46; min-width: 18px; text-align: center; font-variant-numeric: tabular-nums;">
{i + 1}
</span>
<span
on:pointerdown={(e) => onPointerDown(e, i)}
style="color: #3f3f46; font-size: 1.1rem; cursor: grab; line-height: 1; touch-action: none; display: grid; place-items: center; width: 24px; height: 24px;"
>
⠿
</span>
<div style="width: 32px; height: 32px; border-radius: 0.5rem; display: grid; place-items: center; font-size: 0.9rem; flex-shrink: 0; background: {item.bg}; border: 1px solid {item.border};">
{item.emoji}
</div>
<span style="flex: 1; font-size: 0.85rem; font-weight: 500; color: #d4d4d8;">
{item.text}
</span>
<span style="font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); color: #71717a; letter-spacing: 0.04em;">
{item.badge}
</span>
</li>
{/each}
</ul>
</div>
</div>Reorder List
A draggable sortable list with smooth FLIP animations. Grab any item and drag it to a new position — all other items glide out of the way.
How it works
pointerdownon an item captures the dragged element and creates a floating clonepointermovepositions the clone under the cursor and determines the insertion point- Before reordering the DOM, every item’s rect is captured (First)
- The DOM is reordered, new rects are captured (Last), then Invert + Play animates items to their new slots
pointerupdrops the item and removes the clone
Key features
- Pointer Events API for unified mouse + touch support
- FLIP animation ensures items never teleport
- Visual placeholder shows where the item will land
- Grab cursor and elevation change on drag
Use cases
- Task / priority lists
- Playlist ordering
- Layer ordering in design tools