06Tag Input Whose Chips Reorder by Drag
The dragged chip's x-position is compared against neighboring chips' centers, and crossing one swaps their order on the spot while the rest slide over to close the gap.
Best forPriority-ordered tags and sortable filter chips
checkSwapdoSwapswapCompensation

<main class="stage">
<div class="dr is-demo" id="dr">
<ul class="dr__row" id="drRow" role="listbox" aria-label="Tag order, drag to reorder">
<li class="dr__chip" id="drChip0" role="option" aria-selected="false" tabindex="0" data-word="Planning">Planning</li>
<li class="dr__chip" id="drChip1" role="option" aria-selected="false" tabindex="0" data-word="Design">Design</li>
<li class="dr__chip" id="drChip2" role="option" aria-selected="false" tabindex="0" data-word="Dev">Dev</li>
<li class="dr__chip" id="drChip3" role="option" aria-selected="false" tabindex="0" data-word="QA">QA</li>
</ul>
<p class="dr__meta">Order: <b id="drOrder">Plan, Design, Dev, QA</b></p>
<p class="dr__sr" role="status" aria-live="polite" id="drLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 잡은 칩의 화면 중심 x 를 이웃 칩들의 중심 x 와 pointermove 마다 비교해, 지나치는 순간에만
// DOM 순서를 바꾼다(checkSwap). 순서가 바뀔 때 생기는 레이아웃 이동만큼 translateX 를 보정(swapCompensation)해
// 커서 밑에서 칩이 끊기지 않고 이어진다.
@include stage($stage-yellow);
.dr { width: min(100%, 320px); display: flex; flex-direction: column; gap: $sp-2; }
.dr__row {
list-style: none; margin: 0; padding: $sp-2 $sp-3; display: flex; align-items: center; gap: $sp-2;
background: rgba($subject-ink, .06); border: 1px solid rgba($subject-ink, .18);
border-radius: $r-control;
}
.dr__chip {
display: inline-flex; align-items: center; height: 28px; padding: 0 $sp-3;
background: $subject-ink; color: $subject-cream; border-radius: $r-pill;
font-size: 12px; font-weight: 700; white-space: nowrap; cursor: grab;
touch-action: none; user-select: none;
box-shadow: 0 0 0 0 rgba($subject-ink, 0);
transition: box-shadow $dur-quick $ease-out;
}
.dr__chip:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.dr__chip--dragging {
cursor: grabbing; position: relative; z-index: 2;
box-shadow: $shadow-raised;
transition: none;
}
.dr__meta { margin: 0; font-size: 11px; color: rgba($subject-ink, .72); }
.dr__meta b { color: $subject-ink; font-weight: 700; }
.dr__sr {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0;
}
@media (max-width: 340px) {
.dr { width: 100%; }
.dr__row { padding: $sp-2; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
.dr__chip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dr {
width: min(100%, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.dr__row {
list-style: none;
margin: 0;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(23, 20, 26, 0.06);
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
}
.dr__chip {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
cursor: grab;
touch-action: none;
user-select: none;
box-shadow: 0 0 0 0 rgba(23, 20, 26, 0);
transition: box-shadow 220ms ease-out;
}
.dr__chip:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.dr__chip--dragging {
cursor: grabbing;
position: relative;
z-index: 2;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: none;
}
.dr__meta {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
.dr__meta b {
color: #17141a;
font-weight: 700;
}
.dr__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@media (max-width: 340px) {
.dr {
width: 100%;
}
.dr__row {
padding: 8px;
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
.dr__chip {
transition: none;
}
}(function () {
var HOLD_MS = 700, BACK_MS = 700, PAUSE_MS = 500;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('dr');
var row = document.getElementById('drRow');
var orderEl = document.getElementById('drOrder');
var live = document.getElementById('drLive');
function human() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
function orderText() {
return Array.prototype.map.call(row.children, function (c) { return c.dataset.word; }).join(', ');
}
function renderOrder() { orderEl.textContent = orderText(); }
var dragEl = null, startClientX = 0, swapCompensation = 0, lastDx = 0, dragging = false;
function applyTransform(dx) {
lastDx = dx;
dragEl.style.transform = 'translateX(' + (dx + swapCompensation) + 'px)';
}
// 계산 겹: 잡은 칩의 화면 중심 x 를 이웃 칩의 중심 x 와 계속 비교해, 지나치는 순간에만 DOM 순서를 바꾼다.
function checkSwap() {
var dr = dragEl.getBoundingClientRect();
var dragCenter = dr.left + dr.width / 2;
var prev = dragEl.previousElementSibling;
if (prev) {
var pr = prev.getBoundingClientRect();
if (dragCenter < pr.left + pr.width / 2) { doSwap(prev, true); return; }
}
var next = dragEl.nextElementSibling;
if (next) {
var nr = next.getBoundingClientRect();
if (dragCenter > nr.left + nr.width / 2) { doSwap(next, false); return; }
}
}
function doSwap(sibling, moveBefore) {
var oldLeft = dragEl.offsetLeft;
if (moveBefore) { row.insertBefore(dragEl, sibling); }
else { row.insertBefore(sibling, dragEl); }
var newLeft = dragEl.offsetLeft;
swapCompensation += (oldLeft - newLeft);
applyTransform(lastDx);
renderOrder();
live.textContent = orderText();
}
function onPointerMove(e) {
if (!dragging) return;
e.preventDefault();
applyTransform(e.clientX - startClientX);
checkSwap();
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
dragEl.classList.remove('dr__chip--dragging');
dragEl.style.transform = '';
swapCompensation = 0;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
dragEl = null;
}
function startDrag(chip, clientX, trusted) {
if (trusted) human();
dragging = true;
dragEl = chip;
startClientX = clientX;
swapCompensation = 0;
chip.classList.add('dr__chip--dragging');
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
}
Array.prototype.forEach.call(row.children, function (chip) {
chip.addEventListener('pointerdown', function (e) {
startDrag(chip, e.clientX, e.isTrusted);
});
chip.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
human();
var sib = e.key === 'ArrowLeft' ? chip.previousElementSibling : chip.nextElementSibling;
if (!sib) return;
if (e.key === 'ArrowLeft') row.insertBefore(chip, sib); else row.insertBefore(sib, chip);
renderOrder();
live.textContent = orderText();
chip.focus({ preventScroll: true });
});
});
var timer = 0;
function schedule(fn, ms) { timer = window.setTimeout(fn, ms); }
function simFrom(chip, fromX, toX, ms, cb) {
var t0 = null;
function step(ts) {
if (!root.classList.contains('is-demo')) return;
if (t0 === null) t0 = ts;
var p = Math.min(1, (ts - t0) / ms);
var x = fromX + (toX - fromX) * p;
dragEl && onPointerMove({ clientX: x, preventDefault: function () {} });
if (p < 1) { requestAnimationFrame(step); } else { cb && cb(); }
}
requestAnimationFrame(step);
}
function loop() {
if (!root.classList.contains('is-demo')) return;
var chip = row.children[0];
var r0 = chip.getBoundingClientRect();
var startX = r0.left + r0.width / 2;
var endX = startX + 170;
startDrag(chip, startX, false);
simFrom(chip, startX, endX, HOLD_MS, function () {
onPointerUp();
schedule(function () {
if (!root.classList.contains('is-demo')) return;
var back = row.children[row.children.length - 1];
var rb = back.getBoundingClientRect();
var bStart = rb.left + rb.width / 2;
var bEnd = bStart - 170;
startDrag(back, bStart, false);
simFrom(back, bStart, bEnd, BACK_MS, function () {
onPointerUp();
schedule(loop, PAUSE_MS);
});
}, PAUSE_MS);
});
}
renderOrder();
if (!reduce) {
schedule(loop, 300);
} else {
root.classList.remove('is-demo');
}
})();// TagDragReorderChip — vanilla 판(run/397/demo/06-tag-input-drag-reorder-chip/index.html) 의 React 포트.
// 계산 겹(드래그 중심 x 대 이웃 중심 x 비교 + 순서 교체 시 위치 보정)은 vanilla 와 같은 값.
// 실사용 경로는 pointerdown/pointermove/pointerup — navigator.clipboard 등 금지 API 는 쓰지 않는다.
import { useEffect, useRef, useState } from "react";
import styles from "./TagDragReorderChip.module.scss";
type Box<T> = { current: T };
type PointerEvt = { clientX: number; isTrusted?: boolean; preventDefault(): void };
type KeyEvt = { key: string };
type Chip = { id: number; word: string };
const WORDS = ["기획", "디자인", "개발", "QA"];
const HOLD_MS = 700, BACK_MS = 700, PAUSE_MS = 500;
export default function TagDragReorderChip() {
const rowRef = useRef(null) as unknown as Box<HTMLUListElement>;
const chipRefs = useRef({} as { [id: number]: HTMLLIElement }) as unknown as Box<{ [id: number]: HTMLLIElement }>;
const orderRef = useRef(WORDS.map((w, i) => ({ id: i, word: w }))) as unknown as Box<Chip[]>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const dragIdRef = useRef(-1) as unknown as Box<number>;
const startClientXRef = useRef(0) as unknown as Box<number>;
const swapCompRef = useRef(0) as unknown as Box<number>;
const lastDxRef = useRef(0) as unknown as Box<number>;
const timerRef = useRef(0) as unknown as Box<number>;
const [chips, setChips] = useState<Chip[]>(orderRef.current);
const [draggingId, setDraggingId] = useState(-1);
const [isDemo, setIsDemo] = useState(true);
const [live, setLive] = useState("");
function human() {
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function orderText(list: Chip[]) { return list.map((c) => c.word).join(", "); }
function applyTransform(dx: number) {
lastDxRef.current = dx;
const el = chipRefs.current[dragIdRef.current];
if (el) el.style.transform = "translateX(" + (dx + swapCompRef.current) + "px)";
}
function checkSwap() {
const el = chipRefs.current[dragIdRef.current];
if (!el) return;
const dr = el.getBoundingClientRect();
const dragCenter = dr.left + dr.width / 2;
const list = orderRef.current;
const idx = list.findIndex((c) => c.id === dragIdRef.current);
if (idx > 0) {
const prevEl = chipRefs.current[list[idx - 1].id];
const pr = prevEl.getBoundingClientRect();
if (dragCenter < pr.left + pr.width / 2) { doSwap(idx, idx - 1); return; }
}
if (idx < list.length - 1) {
const nextEl = chipRefs.current[list[idx + 1].id];
const nr = nextEl.getBoundingClientRect();
if (dragCenter > nr.left + nr.width / 2) { doSwap(idx, idx + 1); return; }
}
}
function doSwap(a: number, b: number) {
const el = chipRefs.current[dragIdRef.current];
const oldLeft = el.offsetLeft;
const list = orderRef.current.slice();
const tmp = list[a]; list[a] = list[b]; list[b] = tmp;
orderRef.current = list;
setChips(list);
setLive(orderText(list));
// 레이아웃 반영 뒤(다음 tick) 위치 보정 — DOM 순서가 실제로 바뀐 뒤라야 offsetLeft 가 새 값이다
requestAnimationFrame(() => {
const newLeft = el.offsetLeft;
swapCompRef.current += oldLeft - newLeft;
applyTransform(lastDxRef.current);
});
}
function onPointerMove(e: PointerEvt) {
if (dragIdRef.current < 0) return;
e.preventDefault();
applyTransform(e.clientX - startClientXRef.current);
checkSwap();
}
function onPointerUp() {
if (dragIdRef.current < 0) return;
const el = chipRefs.current[dragIdRef.current];
if (el) el.style.transform = "";
dragIdRef.current = -1;
setDraggingId(-1);
swapCompRef.current = 0;
window.removeEventListener("pointermove", onPointerMove as unknown as EventListener);
window.removeEventListener("pointerup", onPointerUp as unknown as EventListener);
}
function startDrag(id: number, clientX: number, trusted: boolean) {
if (trusted) human();
dragIdRef.current = id;
setDraggingId(id);
startClientXRef.current = clientX;
swapCompRef.current = 0;
window.addEventListener("pointermove", onPointerMove as unknown as EventListener);
window.addEventListener("pointerup", onPointerUp as unknown as EventListener);
}
function onKeyDown(chip: Chip, e: KeyEvt) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
human();
const list = orderRef.current;
const idx = list.findIndex((c) => c.id === chip.id);
const j = e.key === "ArrowLeft" ? idx - 1 : idx + 1;
if (j < 0 || j >= list.length) return;
const next = list.slice();
const tmp = next[idx]; next[idx] = next[j]; next[j] = tmp;
orderRef.current = next;
setChips(next);
setLive(orderText(next));
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function simFrom(id: number, fromX: number, toX: number, ms: number, cb: () => void) {
let t0 = 0;
function step(ts: number) {
if (!isDemoRef.current) return;
if (!t0) t0 = ts;
const p = Math.min(1, (ts - t0) / ms);
const x = fromX + (toX - fromX) * p;
onPointerMove({ clientX: x, isTrusted: false, preventDefault: () => {} });
if (p < 1) requestAnimationFrame(step); else cb();
}
requestAnimationFrame(step);
}
function loop() {
if (!isDemoRef.current) return;
const first = orderRef.current[0];
const el = chipRefs.current[first.id];
if (!el) return;
const r0 = el.getBoundingClientRect();
const startX = r0.left + r0.width / 2;
const endX = startX + 170;
startDrag(first.id, startX, false);
simFrom(first.id, startX, endX, HOLD_MS, () => {
onPointerUp();
timerRef.current = window.setTimeout(() => {
if (!isDemoRef.current) return;
const list = orderRef.current;
const last = list[list.length - 1];
const elB = chipRefs.current[last.id];
if (!elB) return;
const rb = elB.getBoundingClientRect();
const bStart = rb.left + rb.width / 2;
const bEnd = bStart - 170;
startDrag(last.id, bStart, false);
simFrom(last.id, bStart, bEnd, BACK_MS, () => {
onPointerUp();
timerRef.current = window.setTimeout(loop, PAUSE_MS);
});
}, PAUSE_MS);
});
}
if (!reduce) {
timerRef.current = window.setTimeout(loop, 300);
} else {
isDemoRef.current = false;
setIsDemo(false);
}
return () => {
window.clearTimeout(timerRef.current);
window.removeEventListener("pointermove", onPointerMove as unknown as EventListener);
window.removeEventListener("pointerup", onPointerUp as unknown as EventListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.dr + " " + (isDemo ? styles.isDemo : "")}>
<ul className={styles.row} ref={rowRef} role="listbox" aria-label="태그 순서, 드래그로 바꾸기">
{chips.map((c) => (
<li
key={c.id}
ref={(el: HTMLLIElement | null) => { if (el) chipRefs.current[c.id] = el; }}
className={styles.chip + " " + (draggingId === c.id ? styles.chipDragging : "")}
role="option" aria-selected="false" tabIndex={0}
onPointerDown={(e: PointerEvt) => startDrag(c.id, e.clientX, !!e.isTrusted)}
onKeyDown={(e: KeyEvt) => onKeyDown(c, e)}
>
{c.word}
</li>
))}
</ul>
<p className={styles.meta}>순서: <b>{orderText(chips)}</b></p>
<p className={styles.sr} role="status">{live}</p>
</div>
</main>
);
}06 Tag Input Whose Chips Reorder by Drag — Measured area 7.892% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · 9 Tag Input UI Patterns With CSS Chips
The post this demo lives in
9 Tag Input UI Patterns With CSS ChipsIt starts where a chip list shows a +3 badge -- a row-width value collapses the overflow, a pasted string splits by a de See all demos in this post
If you like this effect - similar demos
Inputs05 Tag inputCSS Input Design: 9 Form Widgets You Can Paste In
Inputs07 Arrival chip whose date slides over when you change the shipping speedCheckout Page Design — 9 Cart Patterns, No Library
Inputs04 Recent-search chips — recomputing stored timestamps into '3 min ago, 2 days ago' labelsAutocomplete Input: 9 Search Dropdowns