Interactive
DragGrid
Pick up a tile and the others slide out of its way. Works by mouse and touch, and by keyboard: Space lifts a tile, arrows move it, Space drops it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/drag-grid.tsx
"use client";
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { motion, useReducedMotion, type PanInfo } from "motion/react";
export type DragGridItem = {
id: string;
/** Read out by screen readers while the tile moves. */
label: string;
/** Not for links or buttons: the whole tile is the drag handle. */
content: ReactNode;
};
type DragGridProps = {
items: DragGridItem[];
/** Called with the new order. Store it and pass it back as items. */
onChange: (items: DragGridItem[]) => void;
/** Smallest tile width in pixels. The grid fits as many columns as it can. */
minSize?: number;
/** Space between tiles in pixels. */
gap?: number;
/** Ring colour on the tile being moved. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
/** Replaces the tile look. */
className?: string;
};
const moved = (list: DragGridItem[], from: number, to: number) => {
const next = [...list];
next.splice(to, 0, next.splice(from, 1)[0]);
return next;
};
const clamp = (n: number, max: number) => Math.min(Math.max(n, 0), max);
/* DragGrid: pick up a tile and the others slide out of its way. Space lifts a tile, arrows move it, Space drops it. */
export function DragGrid({
items,
onChange,
minSize = 96,
gap = 12,
accent = "#a78bfa",
label = "Sortable tiles",
className = "aspect-square rounded-xl border border-white/10 bg-white/5 text-white",
}: DragGridProps) {
const reduce = useReducedMotion();
const grid = useRef<HTMLDivElement>(null);
const [lifted, setLifted] = useState<string | null>(null);
const [status, setStatus] = useState("");
// React may move the DOM node on reorder, which drops focus.
useEffect(() => {
if (lifted) grid.current?.querySelector<HTMLElement>(`[data-id="${CSS.escape(lifted)}"]`)?.focus();
}, [items, lifted]);
const columns = () => (grid.current ? getComputedStyle(grid.current).gridTemplateColumns.split(" ").length : 1);
// Slot comes from grid maths, not from the tiles, because they are mid-animation.
const onDrag = (id: string, info: PanInfo) => {
if (!grid.current) return;
const box = grid.current.getBoundingClientRect();
const cols = columns();
const rows = Math.ceil(items.length / cols);
const col = clamp(Math.floor((info.point.x - window.scrollX - box.left) / ((box.width + gap) / cols)), cols - 1);
const row = clamp(Math.floor((info.point.y - window.scrollY - box.top) / ((box.height + gap) / rows)), rows - 1);
const to = Math.min(row * cols + col, items.length - 1);
const from = items.findIndex((item) => item.id === id);
if (to !== from) onChange(moved(items, from, to));
};
const onKey = (e: KeyboardEvent, item: DragGridItem, index: number) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
const up = lifted !== item.id;
setLifted(up ? item.id : null);
setStatus(`${item.label} ${up ? "lifted. Use arrow keys, then Space to drop." : `dropped at position ${index + 1}.`}`);
return;
}
if (lifted !== item.id) return;
if (e.key === "Escape") {
setLifted(null);
setStatus(`${item.label} dropped at position ${index + 1}.`);
return;
}
const cols = columns();
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key];
if (!step) return;
e.preventDefault();
const to = index + step;
if (to < 0 || to >= items.length) return;
onChange(moved(items, index, to));
setStatus(`${item.label}, position ${to + 1} of ${items.length}.`);
};
return (
<div
ref={grid}
role="group"
aria-label={label}
className="grid"
style={{ gap, gridTemplateColumns: `repeat(auto-fill, minmax(${minSize}px, 1fr))` }}
>
{items.map((item, i) => (
<motion.div
key={item.id}
layout
drag
dragSnapToOrigin
dragMomentum={false}
onDrag={(_, info) => onDrag(item.id, info)}
whileDrag={{ scale: 1.06, zIndex: 10, boxShadow: `0 0 0 2px ${accent}, 0 16px 32px rgb(0 0 0 / 0.4)` }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
role="button"
tabIndex={0}
aria-pressed={lifted === item.id}
aria-label={`${item.label}, position ${i + 1} of ${items.length}. Press Space to move.`}
data-id={item.id}
onKeyDown={(e) => onKey(e, item, i)}
onBlur={() => setLifted((id) => (id === item.id ? null : id))}
style={lifted === item.id ? { boxShadow: `0 0 0 2px ${accent}` } : undefined}
className={`relative grid cursor-grab touch-none select-none place-items-center active:cursor-grabbing ${className}`}
>
{item.content}
</motion.div>
))}
<p role="status" className="sr-only">
{status}
</p>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/drag-grid.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/drag-grid-demo.tsx
"use client"; import { useState } from "react"; import { Camera, Cloud, Heart, Music, Sparkles, Zap } from "lucide-react"; import { DragGrid, type DragGridItem } from "@/components/ui/drag-grid"; const START = [ { id: "photos", label: "Photos", icon: Camera }, { id: "music", label: "Music", icon: Music }, { id: "cloud", label: "Cloud", icon: Cloud }, { id: "saved", label: "Saved", icon: Heart }, { id: "new", label: "New", icon: Sparkles }, { id: "power", label: "Power", icon: Zap }, ].map(({ id, label, icon: Icon }) => ({ id, label, content: ( <span className="grid justify-items-center gap-1 text-xs text-white/60"> <Icon size={20} className="text-[#a78bfa]" aria-hidden /> {label} </span> ), })); export default function DragGridExample() { const [items, setItems] = useState<DragGridItem[]>(START); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <div className="w-full max-w-[300px]"> <DragGrid items={items} onChange={setItems} minSize={72} gap={10} className="aspect-[5/4] rounded-xl border border-white/10 bg-white/5" /> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | DragGridItem[] | โ | Tiles in order: id, label and content. Content must not hold links or buttons, the whole tile is the handle. |
| onChange | (items) => void | โ | Called with the new order. Store it and pass it back as items. |
| minSize | number | 96 | Smallest tile width in pixels. The grid fits as many columns as it can. |
| gap | number | 12 | Space between tiles in pixels. |
| accent | string | "#a78bfa" | Ring colour on the tile being moved. |
| label | string | "Sortable tiles" | Read by screen readers. |
| className | string | glass square tile | Replaces the tile look. |