Wzorce Średni
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.
Otwórz w Lab
MCP
css javascript vue svelte
Targety: TS JS HTML React Vue Svelte
Kod
*,
*::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