UI Components Medium
File Reorder Grid
Use Pointer Events to reorder file cards in a responsive grid without a library.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* File Reorder Grid */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(120% 90% at 15% -10%, #17233f 0%, #0b1020 55%);
color: #eef2ff;
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
.demo {
width: min(920px, 100%);
margin: auto;
background: #121a2d;
border: 1px solid #263555;
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0006;
}
.demo__head h1 { margin: 0 0 .35rem; font-size: clamp(1.25rem, 3vw, 1.6rem); }
.muted { margin: 0 0 1.5rem; color: #a8b5d1; font-size: .92rem; }
kbd {
font: inherit;
font-size: .82em;
background: #1d2942;
border: 1px solid #33456d;
border-bottom-width: 2px;
border-radius: 5px;
padding: .05em .4em;
}
.grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: .85rem;
}
.item {
position: relative;
display: grid;
gap: .3rem;
justify-items: start;
padding: .95rem;
background: linear-gradient(160deg, #1b2740, #162036);
border: 1px solid #2b3d62;
border-radius: 16px;
cursor: grab;
touch-action: none;
user-select: none;
transition: transform .18s cubic-bezier(.2, .8, .3, 1), box-shadow .18s, border-color .18s;
}
.item:hover { border-color: #3d5488; }
.item:focus-visible { outline: 2px solid #78a9ff; outline-offset: 3px; }
.item__icon {
width: 30px;
height: 30px;
fill: none;
stroke: #78a9ff;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
margin-bottom: .3rem;
}
.item__name {
font-weight: 600;
font-size: .9rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item__meta { font-size: .78rem; color: #8fa0c4; }
/* dragged tile follows the pointer */
.item.is-dragging {
cursor: grabbing;
z-index: 5;
transition: none;
border-color: #78a9ff;
box-shadow: 0 18px 40px #0009, 0 0 0 1px #78a9ff55;
transform: scale(1.04);
}
/* gap left behind by the dragged tile */
.slot {
border: 2px dashed #78a9ff77;
border-radius: 16px;
background: #78a9ff14;
}
.item.is-moved { animation: pop .28s ease; }
@keyframes pop { from { transform: scale(1.06); } to { transform: scale(1); } }
.order {
margin: 1.4rem 0 0;
font-size: .82rem;
color: #8fa0c4;
word-break: break-word;
}
#order-list { color: #cfe0ff; font-family: ui-monospace, "SFMono-Regular", monospace; }
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.item, .item.is-moved { transition: none; animation: none; }
}/* File Reorder Grid — Pointer Events drag reorder with FLIP animation.
No dependencies. Touch + mouse + keyboard. */
(function () {
const grid = document.querySelector("#file-grid");
if (!grid) return;
const orderOut = document.querySelector("#order-list");
const live = document.querySelector("#live");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const items = () => [...grid.querySelectorAll(".item")];
function renderOrder() {
if (orderOut) orderOut.textContent = items().map((i) => i.dataset.name).join(" → ");
// Persistence boundary: POST items().map(i => i.dataset.name) to your API here.
}
/* ---- FLIP: animate siblings from their old box to the new one ---- */
function flip(mutate) {
const first = new Map(items().map((el) => [el, el.getBoundingClientRect()]));
mutate();
if (reduced) return;
for (const el of items()) {
const a = first.get(el);
if (!a || el.classList.contains("is-dragging")) continue;
const b = el.getBoundingClientRect();
const dx = a.left - b.left;
const dy = a.top - b.top;
if (!dx && !dy) continue;
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0,0)" }],
{ duration: 200, easing: "cubic-bezier(.2,.8,.3,1)" }
);
}
}
/* ---------------- pointer drag ---------------- */
let drag = null;
grid.addEventListener("pointerdown", (e) => {
const item = e.target.closest(".item");
if (!item || e.button !== 0 || drag) return;
const rect = item.getBoundingClientRect();
const slot = document.createElement("li");
slot.className = "slot";
slot.style.height = rect.height + "px";
drag = {
item,
slot,
id: e.pointerId,
offX: e.clientX - rect.left,
offY: e.clientY - rect.top,
w: rect.width,
h: rect.height,
moved: false,
};
item.setPointerCapture(e.pointerId);
e.preventDefault();
});
grid.addEventListener("pointermove", (e) => {
if (!drag || e.pointerId !== drag.id) return;
const { item } = drag;
if (!drag.moved) {
drag.moved = true;
item.after(drag.slot);
item.classList.add("is-dragging");
item.setAttribute("aria-selected", "true");
Object.assign(item.style, {
position: "fixed",
width: drag.w + "px",
height: drag.h + "px",
left: "0",
top: "0",
margin: "0",
pointerEvents: "none",
});
}
item.style.transform =
`translate(${e.clientX - drag.offX}px, ${e.clientY - drag.offY}px) scale(1.04)`;
// Which tile is under the pointer? Move the placeholder slot there.
const cx = e.clientX;
const cy = e.clientY;
for (const el of items()) {
if (el === item) continue;
const r = el.getBoundingClientRect();
if (cx < r.left || cx > r.right || cy < r.top || cy > r.bottom) continue;
const target = cx < r.left + r.width / 2 ? el : el.nextSibling;
if (target !== drag.slot) flip(() => grid.insertBefore(drag.slot, target));
break;
}
});
function endDrag(e) {
if (!drag || (e && e.pointerId !== drag.id)) return;
const { item, slot, moved } = drag;
drag = null;
if (moved) {
item.removeAttribute("style");
item.classList.remove("is-dragging");
item.setAttribute("aria-selected", "false");
slot.replaceWith(item);
renderOrder();
announce(item);
}
item.focus({ preventScroll: true });
}
grid.addEventListener("pointerup", endDrag);
grid.addEventListener("pointercancel", endDrag);
/* ---------------- keyboard reorder ---------------- */
grid.addEventListener("keydown", (e) => {
const item = e.target.closest(".item");
if (!item) return;
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!dir) return;
e.preventDefault();
const list = items();
const j = list.indexOf(item) + dir;
if (j < 0 || j >= list.length) return;
flip(() => (dir === 1 ? list[j].after(item) : list[j].before(item)));
item.focus({ preventScroll: true });
item.classList.remove("is-moved");
void item.offsetWidth;
item.classList.add("is-moved");
renderOrder();
announce(item);
});
function announce(item) {
if (!live) return;
const list = items();
live.textContent =
`${item.dataset.name} moved to position ${list.indexOf(item) + 1} of ${list.length}.`;
}
renderOrder();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File Reorder Grid</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="File Reorder Grid">
<header class="demo__head">
<h1>File reorder grid</h1>
<p class="muted">Drag any tile to reorder. Keyboard: focus a tile and press <kbd>←</kbd> / <kbd>→</kbd>.</p>
</header>
<ul id="file-grid" class="grid" role="listbox" aria-label="Files, reorderable">
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="brief.pdf">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 2h8l4 4v16H6z"/><path d="M14 2v4h4"/></svg>
<span class="item__name">brief.pdf</span>
<span class="item__meta">248 KB</span>
</li>
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="moodboard.png">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M4 18l6-5 4 3 3-2 4 4"/></svg>
<span class="item__name">moodboard.png</span>
<span class="item__meta">1.4 MB</span>
</li>
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="launch.mp4">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M10 9l5 3-5 3z"/></svg>
<span class="item__name">launch.mp4</span>
<span class="item__meta">18 MB</span>
</li>
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="metrics.csv">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M9 4v16"/></svg>
<span class="item__name">metrics.csv</span>
<span class="item__meta">62 KB</span>
</li>
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="notes.md">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 2h12v20H6z"/><path d="M9 8h6M9 12h6M9 16h4"/></svg>
<span class="item__name">notes.md</span>
<span class="item__meta">4 KB</span>
</li>
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="logo.svg">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4v16M4 12h16"/></svg>
<span class="item__name">logo.svg</span>
<span class="item__meta">9 KB</span>
</li>
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="archive.zip">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M12 3v6M10 11h4v4h-4z"/></svg>
<span class="item__name">archive.zip</span>
<span class="item__meta">7.2 MB</span>
</li>
<li class="item" tabindex="0" role="option" aria-selected="false" data-name="theme.css">
<svg class="item__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6l-4 6 4 6M16 6l4 6-4 6"/></svg>
<span class="item__name">theme.css</span>
<span class="item__meta">31 KB</span>
</li>
</ul>
<p class="order">Order: <span id="order-list"></span></p>
<p class="sr-only" role="status" aria-live="polite" id="live"></p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function DragDropFileGrid() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>File Reorder Grid</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}File Reorder Grid
Use Pointer Events to reorder file cards in a responsive grid without a library.
Support notes
Native HTML5 DnD covers desktop semantics while Pointer Events handle touch-friendly reorder surfaces. Add a persistence callback where the demo updates the DOM.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.