CSS code
Edit on CodePen ↗
StackBlitz ↗
HTML
Copy
<div class="gx"><span class="lbl">Handle styles</span>
<p class="gx-hint" id="gx-hint1">Drag a row, or focus its handle, press Space and use the arrow keys.</p>
<div class="gx-grid">
<section class="gx-card gx-dots" data-dd data-ph="slot" aria-labelledby="gx-dots-t"><h3 id="gx-dots-t">Dot grip</h3><p>The familiar six-dot grip on the left.</p>
<ul class="gx-list" data-dd-list="dots" aria-label="Playlist"><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Northern Lights</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Paper Planes</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Slow Tide</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="gx-card gx-bars" data-dd data-ph="slot" aria-labelledby="gx-bars-t"><h3 id="gx-bars-t">Bars on the right</h3><p>Keeps the start of each row for content.</p>
<ul class="gx-list" data-dd-list="bars" aria-label="Checkout steps"><li class="gx-row" data-dd-item><span data-dd-name>Shipping</span><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li><li class="gx-row" data-dd-item><span data-dd-name>Payment</span><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li><li class="gx-row" data-dd-item><span data-dd-name>Review</span><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="gx-card gx-strip" data-dd data-ph="slot" aria-labelledby="gx-strip-t"><h3 id="gx-strip-t">Edge strip</h3><p>A full-height strip: a big target for touch.</p>
<ul class="gx-list" data-dd-list="strip" aria-label="Homepage blocks"><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span data-dd-name>Hero</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span data-dd-name>Customer logos</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span data-dd-name>Pricing</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="gx-card gx-move" data-dd data-dd-handle-only data-ph="slot" aria-labelledby="gx-move-t"><h3 id="gx-move-t">Handle only</h3><p>Rows ignore the pointer, so text stays selectable.</p>
<ul class="gx-list" data-dd-list="move" aria-label="Signature lines"><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span data-dd-name>Mara Jensen</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span data-dd-name>Head of Design</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span data-dd-name>[email protected] </span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
</div></div>
<div class="gx"><span class="lbl">Placeholder styles</span>
<div class="gx-grid">
<section class="gx-card" data-dd data-ph="slot" aria-labelledby="gx-slot-t"><h3 id="gx-slot-t">Dashed slot</h3><p>Holds the exact size of the item being moved.</p>
<span class="gx-tag" aria-hidden="true">Mid-drag</span><div class="gx-preview" data-ph="slot" aria-hidden="true"><div class="gx-row"><span>Intro video</span></div><div class="gx-row" data-dragging><span>Lesson 1: Setup</span></div><div class="gx-row"><span>Lesson 2: Layout</span></div><div class="gx-row gx-ghost"><span>Lesson 1: Setup</span></div></div><span class="gx-tag" aria-hidden="true">Try it</span>
<ul class="gx-list" data-dd-list="slot" aria-label="Course outline, dashed slot"><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Intro video</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Lesson 1: Setup</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="gx-card" data-dd data-ph="line" aria-labelledby="gx-line-t"><h3 id="gx-line-t">Insertion line</h3><p>Collapses to a line, so the list barely shifts.</p>
<span class="gx-tag" aria-hidden="true">Mid-drag</span><div class="gx-preview" data-ph="line" aria-hidden="true"><div class="gx-row"><span>Intro video</span></div><div class="gx-row" data-dragging><span>Lesson 1: Setup</span></div><div class="gx-row"><span>Lesson 2: Layout</span></div><div class="gx-row gx-ghost"><span>Lesson 1: Setup</span></div></div><span class="gx-tag" aria-hidden="true">Try it</span>
<ul class="gx-list" data-dd-list="line" aria-label="Course outline, insertion line"><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Intro video</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Lesson 1: Setup</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="gx-card" data-dd data-ph="fade" aria-labelledby="gx-fade-t"><h3 id="gx-fade-t">Faded original</h3><p>Leaves the item in place at low opacity.</p>
<span class="gx-tag" aria-hidden="true">Mid-drag</span><div class="gx-preview" data-ph="fade" aria-hidden="true"><div class="gx-row"><span>Intro video</span></div><div class="gx-row" data-dragging><span>Lesson 1: Setup</span></div><div class="gx-row"><span>Lesson 2: Layout</span></div><div class="gx-row gx-ghost"><span>Lesson 1: Setup</span></div></div><span class="gx-tag" aria-hidden="true">Try it</span>
<ul class="gx-list" data-dd-list="fade" aria-label="Course outline, faded original"><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Intro video</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Lesson 1: Setup</span></li><li class="gx-row" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint1"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
</div></div>
<div><span class="lbl">Drop zone states</span>
<section class="gx gx-card" data-dd="wrap" aria-labelledby="gx-z-t">
<h3 id="gx-z-t" class="sr-only">Drop zone states</h3>
<div class="gx-states">
<div class="gx-zone"><b>Idle</b>No drag in progress.</div>
<div class="gx-zone is-ready"><b>Ready</b>A drag started: every valid target shows a dashed outline.</div>
<div class="gx-zone is-over"><b>Over</b>The item is above this target and will land here.</div>
<div class="gx-zone is-full"><b>Full</b>The target is at its limit and refuses the item.</div>
</div>
<p class="gx-hint" id="gx-hint3">Drag a file into a folder, or focus its handle, press Space and use Left and Right. Shared holds 2 files.</p>
<div class="gx-folders">
<div class="gx-zone gx-target" data-dd-zone><div class="gx-fh">Inbox<span><span data-dd-count="gx-inbox">3</span></span></div><ul class="gx-files" id="gx-inbox" data-dd-list="files" aria-label="Inbox"><li class="gx-file" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint3"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>brief-v3.pdf</span></li><li class="gx-file" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint3"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>logo.svg</span></li><li class="gx-file" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint3"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>meeting-notes.md</span></li></ul></div>
<div class="gx-zone gx-target" data-dd-zone><div class="gx-fh">Archive<span><span data-dd-count="gx-archive">0</span></span></div><ul class="gx-files" id="gx-archive" data-dd-list="files" aria-label="Archive"></ul></div>
<div class="gx-zone gx-target" data-dd-zone><div class="gx-fh">Shared<span><span data-dd-count="gx-shared">1</span> / 2</span></div><ul class="gx-files" id="gx-shared" data-dd-list="files" data-dd-max="2" aria-label="Shared"><li class="gx-file" data-dd-item><button class="gx-h" type="button" data-dd-handle aria-describedby="gx-hint3"><svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>budget.xlsx</span></li></ul></div>
</div>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
Copy
/* Handles, placeholders and drop-zone states. Every list runs on the same script; the look comes from
data-dd-handle buttons, the root's data-ph (slot, line or fade) and the data-dd-active / data-over / data-full states. */
.gx{--gx-accent:#be185d;--gx-tint:#fdf0f6;--gx-line:#e4e7ec;--gx-muted:#5d6679;--gx-ink:#101828;--gx-danger:#b42318;color:var(--gx-ink)}
.gx-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
.gx-card{background:#fff;border:1px solid var(--gx-line);border-radius:14px;padding:14px;box-shadow:0 1px 2px rgba(16,24,40,.05)}
.gx-card h3{font-size:.88rem}
.gx-card>p{font-size:.78rem;color:var(--gx-muted);margin:2px 0 10px}
.gx-hint{font-size:.8rem;color:var(--gx-muted);margin-bottom:10px}
.gx-list{list-style:none;display:grid;grid-template-columns:minmax(0,1fr);gap:6px}
.gx-row{display:flex;align-items:center;gap:8px;min-height:40px;padding:4px 10px 4px 4px;background:#fff;border:1px solid var(--gx-line);border-radius:10px;font-size:.86rem;font-weight:600;cursor:grab;user-select:none}
.gx-row>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.gx-h{flex:none;display:grid;place-items:center;width:28px;height:30px;border:0;border-radius:7px;background:none;color:#667085;cursor:grab;touch-action:none}
.gx-h:hover{background:#f2f4f7;color:var(--gx-ink)}
.gx-h:focus-visible{outline:2px solid var(--gx-accent);outline-offset:1px}
.gx-h svg{width:16px;height:16px}
/* handle variants */
.gx-bars .gx-row{padding:4px 4px 4px 12px}
.gx-strip .gx-row{padding:0 10px 0 0;overflow:hidden}
.gx-strip .gx-h{align-self:stretch;width:22px;height:auto;border-radius:0;background:var(--gx-tint);color:var(--gx-accent);border-right:1px solid #f6cfe0}
.gx-strip .gx-h:focus-visible{outline-offset:-3px}
.gx-move .gx-row{cursor:auto;user-select:text}
.gx-move .gx-h{cursor:move;border:1px solid #d0d5dd}
/* placeholder variants */
.gx-row[data-dd-ghost]{box-shadow:0 14px 30px -10px rgba(16,24,40,.4);rotate:-1deg}
[data-ph="slot"] [data-dragging]{background:var(--gx-tint);border:2px dashed #e68bb4}
[data-ph="slot"] [data-dragging]>*{visibility:hidden}
[data-ph="line"] [data-dragging]{position:relative;min-height:0;height:4px;padding:0;margin:3px 0;border:0;border-radius:2px;background:var(--gx-accent);overflow:visible}
[data-ph="line"] [data-dragging]>*{display:none}
[data-ph="line"] [data-dragging]::before{content:"";position:absolute;left:-2px;top:-4px;width:12px;height:12px;border-radius:50%;background:#fff;border:3px solid var(--gx-accent);box-sizing:border-box}
[data-ph="fade"] [data-dragging]{opacity:.6;border-style:dashed;border-color:#98a2b3}
.gx-preview{position:relative;display:grid;gap:6px;padding:10px 10px 54px;margin-bottom:10px;border-radius:10px;background:#f8f9fb;border:1px solid var(--gx-line)}
.gx-preview .gx-row{cursor:default;padding-left:12px}
.gx-preview .gx-ghost{position:absolute;left:34px;right:-6px;bottom:10px;background:#fff;box-shadow:0 14px 30px -10px rgba(16,24,40,.4);rotate:-1.5deg}
.gx-tag{display:inline-block;font-size:.66rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--gx-muted);margin-bottom:6px}
.gx-row[data-grabbed]{border-color:var(--gx-accent);box-shadow:0 0 0 2px var(--gx-accent)}
/* drop-zone states */
.gx-states{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:14px}
.gx-zone{display:grid;align-content:start;gap:2px;min-height:86px;padding:10px 12px;border-radius:12px;background:#f8f9fb;outline:2px dashed transparent;outline-offset:-2px;border:1px solid var(--gx-line);font-size:.78rem;color:var(--gx-muted);transition:background-color .15s,outline-color .15s}
.gx-zone b{font-size:.84rem;color:var(--gx-ink)}
.gx-zone.is-ready,.gx[data-dd-active] .gx-target{outline-color:#98a2b3;border-color:transparent}
.gx-zone.is-over,.gx .gx-target:has(>[data-over]){outline-style:solid;outline-color:var(--gx-accent);background:var(--gx-tint);border-color:transparent}
.gx-zone.is-full,.gx .gx-target:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed])){outline-style:solid;outline-color:var(--gx-danger);background:#fef3f2;border-color:transparent}
.gx-zone.is-full b{color:var(--gx-danger)}
.gx-folders{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}
.gx-fh{display:flex;justify-content:space-between;gap:8px;font-size:.84rem;font-weight:700;margin-bottom:6px}
.gx-fh span{font-size:.75rem;color:var(--gx-muted);font-variant-numeric:tabular-nums}
.gx-files{list-style:none;display:flex;flex-wrap:wrap;align-content:flex-start;gap:6px;min-height:40px}
.gx-files[data-empty]::after{content:"Empty folder";align-self:center;font-size:.78rem;color:var(--gx-muted)}
.gx-file{display:flex;align-items:center;gap:4px;padding:2px 10px 2px 2px;background:#fff;border:1px solid #d0d5dd;border-radius:8px;font-size:.8rem;font-weight:600;cursor:grab;user-select:none}
.gx-file .gx-ico{width:16px;height:16px;color:var(--gx-accent)}
.gx-file[data-dragging]{background:var(--gx-tint);border:2px dashed #e68bb4}
.gx-file[data-dragging]>*{visibility:hidden}
.gx-file[data-dd-ghost]{box-shadow:0 14px 30px -10px rgba(16,24,40,.4);rotate:-2deg}
.gx-file[data-grabbed]{border-color:var(--gx-accent);box-shadow:0 0 0 2px var(--gx-accent)}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media (prefers-reduced-motion:reduce){.gx-zone{transition:none}.gx-row[data-dd-ghost],.gx-file[data-dd-ghost],.gx-preview .gx-ghost{rotate:none}}
JavaScript
Copy
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', live = root.querySelector('[data-dd-live]');
const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
const name = el => el.querySelector('[data-dd-name]').textContent.trim();
const full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
const pos = el => { const l = el.parentElement, all = items(l);
return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
const marks = () => root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`);
l.toggleAttribute('data-empty', !n); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
const changed = () => {
marks();
root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
root.dispatchEvent(new Event('dd:change', { bubbles: true }));
};
let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);
root.addEventListener('pointerdown', e => {
const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
if (!item || e.button > 0 || kb) return;
if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
});
root.addEventListener('pointermove', e => {
if (!drag || e.pointerId !== pid) return;
if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
clearOver(list);
if (!list || !root.contains(list) || drag.contains(list)) return;
list.setAttribute('data-over', '');
if (list !== drag.parentElement && full(list)) return;
const over = items(list).find(c => c !== drag && c.contains(hit));
if (over) { const r = over.getBoundingClientRect();
const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
} else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
});
const end = e => {
if (!drag || e.pointerId !== pid) return;
if (ghost) {
ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
changed(); dragged = true; setTimeout(() => { dragged = false; });
}
drag = null;
};
root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
function start() {
const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
}
// Keyboard and screen reader mode: the handle is a toggle button (aria-pressed) that picks the item up.
const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); clearOver(); kb = null; changed(); say(msg); };
const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
root.addEventListener('click', e => {
const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) return;
const item = h.closest('[data-dd-item]');
if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); item.parentElement.setAttribute('data-over', ''); h.focus();
say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
});
root.addEventListener('keydown', e => {
if (!kb || e.target !== kbh) return;
if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
e.preventDefault();
const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
if (side && ls.length > 1) {
const t = ls[ls.indexOf(l) + d];
if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
return move(() => t.insertBefore(kb, items(t)[i] || null));
}
const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
});
root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
changed();
});
Bootstrap 5 code
Edit on CodePen ↗
StackBlitz ↗
Requires: Bootstrap 5.3.8 CSS
HTML
Copy
<div class="gx"><span class="lbl">Handle styles</span>
<p class="gx-hint text-body-secondary mb-2" id="gx-hint1">Drag a row, or focus its handle, press Space and use the arrow keys.</p>
<div class="row row-cols-1 row-cols-sm-2 row-cols-lg-4 g-3">
<div class="col"><section class="card h-100 gx-dots" data-dd data-ph="slot" aria-labelledby="gx-dots-t"><h3 class="fw-bold mb-0" id="gx-dots-t">Dot grip</h3><p class="text-body-secondary">The familiar six-dot grip on the left.</p>
<ul class="gx-list d-grid gap-2 list-unstyled mb-0" data-dd-list="dots" aria-label="Playlist"><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Northern Lights</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Paper Planes</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Slow Tide</span></li></ul>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p></section></div>
<div class="col"><section class="card h-100 gx-bars" data-dd data-ph="slot" aria-labelledby="gx-bars-t"><h3 class="fw-bold mb-0" id="gx-bars-t">Bars on the right</h3><p class="text-body-secondary">Keeps the start of each row for content.</p>
<ul class="gx-list d-grid gap-2 list-unstyled mb-0" data-dd-list="bars" aria-label="Checkout steps"><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><span class="text-truncate" data-dd-name>Shipping</span><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><span class="text-truncate" data-dd-name>Payment</span><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><span class="text-truncate" data-dd-name>Review</span><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li></ul>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p></section></div>
<div class="col"><section class="card h-100 gx-strip" data-dd data-ph="slot" aria-labelledby="gx-strip-t"><h3 class="fw-bold mb-0" id="gx-strip-t">Edge strip</h3><p class="text-body-secondary">A full-height strip: a big target for touch.</p>
<ul class="gx-list d-grid gap-2 list-unstyled mb-0" data-dd-list="strip" aria-label="Homepage blocks"><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span class="text-truncate" data-dd-name>Hero</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span class="text-truncate" data-dd-name>Customer logos</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span class="text-truncate" data-dd-name>Pricing</span></li></ul>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p></section></div>
<div class="col"><section class="card h-100 gx-move" data-dd data-dd-handle-only data-ph="slot" aria-labelledby="gx-move-t"><h3 class="fw-bold mb-0" id="gx-move-t">Handle only</h3><p class="text-body-secondary">Rows ignore the pointer, so text stays selectable.</p>
<ul class="gx-list d-grid gap-2 list-unstyled mb-0" data-dd-list="move" aria-label="Signature lines"><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="text-truncate" data-dd-name>Mara Jensen</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="text-truncate" data-dd-name>Head of Design</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="text-truncate" data-dd-name>[email protected] </span></li></ul>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p></section></div>
</div></div>
<div class="gx"><span class="lbl">Placeholder styles</span>
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col"><section class="card h-100" data-dd data-ph="slot" aria-labelledby="gx-slot-t"><h3 class="fw-bold mb-0" id="gx-slot-t">Dashed slot</h3><p class="text-body-secondary">Holds the exact size of the item being moved.</p>
<span class="gx-tag d-block fw-bolder text-uppercase text-body-secondary" aria-hidden="true">Mid-drag</span><div class="gx-preview d-grid gap-2" data-ph="slot" aria-hidden="true"><div class="gx-row d-flex align-items-center fw-semibold"><span>Intro video</span></div><div class="gx-row d-flex align-items-center fw-semibold" data-dragging><span>Lesson 1: Setup</span></div><div class="gx-row d-flex align-items-center fw-semibold"><span>Lesson 2: Layout</span></div><div class="gx-row gx-ghost d-flex align-items-center fw-semibold"><span>Lesson 1: Setup</span></div></div><span class="gx-tag d-block fw-bolder text-uppercase text-body-secondary" aria-hidden="true">Try it</span>
<ul class="gx-list d-grid gap-2 list-unstyled mb-0" data-dd-list="slot" aria-label="Course outline, dashed slot"><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Intro video</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Lesson 1: Setup</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p></section></div>
<div class="col"><section class="card h-100" data-dd data-ph="line" aria-labelledby="gx-line-t"><h3 class="fw-bold mb-0" id="gx-line-t">Insertion line</h3><p class="text-body-secondary">Collapses to a line, so the list barely shifts.</p>
<span class="gx-tag d-block fw-bolder text-uppercase text-body-secondary" aria-hidden="true">Mid-drag</span><div class="gx-preview d-grid gap-2" data-ph="line" aria-hidden="true"><div class="gx-row d-flex align-items-center fw-semibold"><span>Intro video</span></div><div class="gx-row d-flex align-items-center fw-semibold" data-dragging><span>Lesson 1: Setup</span></div><div class="gx-row d-flex align-items-center fw-semibold"><span>Lesson 2: Layout</span></div><div class="gx-row gx-ghost d-flex align-items-center fw-semibold"><span>Lesson 1: Setup</span></div></div><span class="gx-tag d-block fw-bolder text-uppercase text-body-secondary" aria-hidden="true">Try it</span>
<ul class="gx-list d-grid gap-2 list-unstyled mb-0" data-dd-list="line" aria-label="Course outline, insertion line"><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Intro video</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Lesson 1: Setup</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p></section></div>
<div class="col"><section class="card h-100" data-dd data-ph="fade" aria-labelledby="gx-fade-t"><h3 class="fw-bold mb-0" id="gx-fade-t">Faded original</h3><p class="text-body-secondary">Leaves the item in place at low opacity.</p>
<span class="gx-tag d-block fw-bolder text-uppercase text-body-secondary" aria-hidden="true">Mid-drag</span><div class="gx-preview d-grid gap-2" data-ph="fade" aria-hidden="true"><div class="gx-row d-flex align-items-center fw-semibold"><span>Intro video</span></div><div class="gx-row d-flex align-items-center fw-semibold" data-dragging><span>Lesson 1: Setup</span></div><div class="gx-row d-flex align-items-center fw-semibold"><span>Lesson 2: Layout</span></div><div class="gx-row gx-ghost d-flex align-items-center fw-semibold"><span>Lesson 1: Setup</span></div></div><span class="gx-tag d-block fw-bolder text-uppercase text-body-secondary" aria-hidden="true">Try it</span>
<ul class="gx-list d-grid gap-2 list-unstyled mb-0" data-dd-list="fade" aria-label="Course outline, faded original"><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Intro video</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Lesson 1: Setup</span></li><li class="gx-row d-flex align-items-center gap-2 fw-semibold" data-dd-item><button class="gx-h btn d-grid flex-shrink-0" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="text-truncate" data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p></section></div>
</div></div>
<div><span class="lbl">Drop zone states</span>
<section class="gx card" data-dd="wrap" aria-labelledby="gx-z-t">
<h3 id="gx-z-t" class="visually-hidden">Drop zone states</h3>
<div class="row row-cols-2 row-cols-md-4 g-2 mb-3">
<div class="col"><div class="gx-zone d-grid align-content-start gap-1 h-100 text-body-secondary"><b>Idle</b>No drag in progress.</div></div>
<div class="col"><div class="gx-zone d-grid align-content-start gap-1 h-100 text-body-secondary is-ready"><b>Ready</b>A drag started: every valid target shows a dashed outline.</div></div>
<div class="col"><div class="gx-zone d-grid align-content-start gap-1 h-100 text-body-secondary is-over"><b>Over</b>The item is above this target and will land here.</div></div>
<div class="col"><div class="gx-zone d-grid align-content-start gap-1 h-100 text-body-secondary is-full"><b>Full</b>The target is at its limit and refuses the item.</div></div>
</div>
<p class="gx-hint text-body-secondary mb-2" id="gx-hint3">Drag a file into a folder, or focus its handle, press Space and use Left and Right. Shared holds 2 files.</p>
<div class="row row-cols-1 row-cols-sm-3 g-2">
<div class="col"><div class="gx-zone gx-target h-100" data-dd-zone><div class="gx-fh d-flex justify-content-between gap-2 fw-bold mb-2">Inbox<span class="badge rounded-pill"><span data-dd-count="gx-inbox">3</span></span></div><ul class="gx-files d-flex flex-wrap gap-2 list-unstyled mb-0" id="gx-inbox" data-dd-list="files" aria-label="Inbox"><li class="gx-file d-flex align-items-center gap-1 fw-semibold" data-dd-item><button class="gx-h btn d-grid" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>brief-v3.pdf</span></li><li class="gx-file d-flex align-items-center gap-1 fw-semibold" data-dd-item><button class="gx-h btn d-grid" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>logo.svg</span></li><li class="gx-file d-flex align-items-center gap-1 fw-semibold" data-dd-item><button class="gx-h btn d-grid" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>meeting-notes.md</span></li></ul></div></div>
<div class="col"><div class="gx-zone gx-target h-100" data-dd-zone><div class="gx-fh d-flex justify-content-between gap-2 fw-bold mb-2">Archive<span class="badge rounded-pill"><span data-dd-count="gx-archive">0</span></span></div><ul class="gx-files d-flex flex-wrap gap-2 list-unstyled mb-0" id="gx-archive" data-dd-list="files" aria-label="Archive"></ul></div></div>
<div class="col"><div class="gx-zone gx-target h-100" data-dd-zone><div class="gx-fh d-flex justify-content-between gap-2 fw-bold mb-2">Shared<span class="badge rounded-pill"><span data-dd-count="gx-shared">1</span> / 2</span></div><ul class="gx-files d-flex flex-wrap gap-2 list-unstyled mb-0" id="gx-shared" data-dd-list="files" data-dd-max="2" aria-label="Shared"><li class="gx-file d-flex align-items-center gap-1 fw-semibold" data-dd-item><button class="gx-h btn d-grid" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="m-auto" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="gx-ico" width="16" height="16" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>budget.xlsx</span></li></ul></div></div>
</div>
<p class="visually-hidden" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
Copy
/* Handles, placeholders and drop-zone states for Bootstrap 5: .card panels in a .row grid, .btn handles and
.badge counts; these rules hold the three placeholder looks (data-ph) and the target states. */
.gx{--bs-secondary-color:#5d6679;--gx-accent:#be185d;--gx-tint:#fdf0f6;--gx-danger:#b42318;color:#101828}
.gx .card{--bs-card-border-color:#e4e7ec;--bs-card-border-radius:14px;padding:14px;box-shadow:0 1px 2px rgba(16,24,40,.05)}
.gx h3{font-size:.88rem}
.gx .card>p{font-size:.78rem;margin:2px 0 10px}
.gx-hint{font-size:.8rem}
.gx-row{min-height:40px;padding:4px 10px 4px 4px;background:#fff;color:#101828;border:1px solid #e4e7ec;border-radius:10px;font-size:.86rem;cursor:grab;user-select:none}
.gx-row>span{flex:1;min-width:0}
.gx-list{grid-template-columns:minmax(0,1fr)}
.gx-h{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-color:#667085;--bs-btn-hover-bg:#f2f4f7;--bs-btn-hover-color:#101828;--bs-btn-active-bg:#f2f4f7;
--bs-btn-border-width:0;--bs-btn-border-radius:7px;width:28px;height:30px;cursor:grab;touch-action:none}
.gx-h:focus-visible{outline:2px solid var(--gx-accent);outline-offset:1px;box-shadow:none}
.gx-bars .gx-row{padding:4px 4px 4px 12px}
.gx-strip .gx-row{padding:0 10px 0 0;overflow:hidden}
.gx-strip .gx-h{--bs-btn-bg:var(--gx-tint);--bs-btn-color:var(--gx-accent);--bs-btn-hover-bg:#fbe3ee;--bs-btn-hover-color:var(--gx-accent);--bs-btn-border-radius:0;align-self:stretch;width:22px;height:auto;border-right:1px solid #f6cfe0}
.gx-strip .gx-h:focus-visible{outline-offset:-3px}
.gx-move .gx-row{cursor:auto;user-select:text}
.gx-move .gx-h{cursor:move;--bs-btn-border-width:1px;--bs-btn-border-color:#d0d5dd;--bs-btn-hover-border-color:#d0d5dd}
.gx-row[data-dd-ghost]{box-shadow:0 14px 30px -10px rgba(16,24,40,.4);rotate:-1deg}
[data-ph="slot"] [data-dragging]{background:var(--gx-tint);border:2px dashed #e68bb4}
[data-ph="slot"] [data-dragging]>*{visibility:hidden}
[data-ph="line"] [data-dragging]{position:relative;min-height:0;height:4px;padding:0;margin:3px 0;border:0;border-radius:2px;background:var(--gx-accent);overflow:visible}
[data-ph="line"] [data-dragging]>*{display:none!important}
[data-ph="line"] [data-dragging]::before{content:"";position:absolute;left:-2px;top:-4px;width:12px;height:12px;border-radius:50%;background:#fff;border:3px solid var(--gx-accent)}
[data-ph="fade"] [data-dragging]{opacity:.6;border-style:dashed;border-color:#98a2b3}
.gx-preview{position:relative;padding:10px 10px 54px;margin-bottom:10px;border-radius:10px;background:#f8f9fb;border:1px solid #e4e7ec}
.gx-preview .gx-row{cursor:default;padding-left:12px}
.gx-preview .gx-ghost{position:absolute;left:34px;right:-6px;bottom:10px;background:#fff;box-shadow:0 14px 30px -10px rgba(16,24,40,.4);rotate:-1.5deg}
.gx-tag{font-size:.66rem;letter-spacing:.06em;margin-bottom:6px}
.gx-row[data-grabbed]{border-color:var(--gx-accent);box-shadow:0 0 0 2px var(--gx-accent)}
.gx-zone{min-height:86px;padding:10px 12px;border-radius:12px;background:#f8f9fb;outline:2px dashed transparent;outline-offset:-2px;border:1px solid #e4e7ec;font-size:.78rem;transition:background-color .15s,outline-color .15s}
.gx-zone b{font-size:.84rem;color:#101828}
.gx-zone.is-ready,.gx[data-dd-active] .gx-target{outline-color:#98a2b3;border-color:transparent}
.gx-zone.is-over,.gx .gx-target:has(>[data-over]){outline-style:solid;outline-color:var(--gx-accent);background:var(--gx-tint);border-color:transparent}
.gx-zone.is-full,.gx .gx-target:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed])){outline-style:solid;outline-color:var(--gx-danger);background:#fef3f2;border-color:transparent}
.gx-zone.is-full b{color:var(--gx-danger)}
.gx-fh{font-size:.84rem}
.gx-fh .badge{font-size:.72rem;background:#fff;color:#344054;border:1px solid #e4e7ec;font-variant-numeric:tabular-nums}
.gx-files{min-height:40px;align-content:flex-start}
.gx-files[data-empty]::after{content:"Empty folder";align-self:center;font-size:.78rem;color:#5d6679}
.gx-file{padding:2px 10px 2px 2px;background:#fff;border:1px solid #d0d5dd;border-radius:8px;font-size:.8rem;cursor:grab;user-select:none}
.gx-file .gx-ico{color:var(--gx-accent)}
.gx-file[data-dragging]{background:var(--gx-tint);border:2px dashed #e68bb4}
.gx-file[data-dragging]>*{visibility:hidden}
.gx-file[data-dd-ghost]{box-shadow:0 14px 30px -10px rgba(16,24,40,.4);rotate:-2deg}
.gx-file[data-grabbed]{border-color:var(--gx-accent);box-shadow:0 0 0 2px var(--gx-accent)}
@media (prefers-reduced-motion:reduce){.gx-zone{transition:none}.gx-row[data-dd-ghost],.gx-file[data-dd-ghost],.gx-preview .gx-ghost{rotate:none}}
JavaScript
Copy
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', live = root.querySelector('[data-dd-live]');
const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
const name = el => el.querySelector('[data-dd-name]').textContent.trim();
const full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
const pos = el => { const l = el.parentElement, all = items(l);
return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
const marks = () => root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`);
l.toggleAttribute('data-empty', !n); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
const changed = () => {
marks();
root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
root.dispatchEvent(new Event('dd:change', { bubbles: true }));
};
let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);
root.addEventListener('pointerdown', e => {
const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
if (!item || e.button > 0 || kb) return;
if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
});
root.addEventListener('pointermove', e => {
if (!drag || e.pointerId !== pid) return;
if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
clearOver(list);
if (!list || !root.contains(list) || drag.contains(list)) return;
list.setAttribute('data-over', '');
if (list !== drag.parentElement && full(list)) return;
const over = items(list).find(c => c !== drag && c.contains(hit));
if (over) { const r = over.getBoundingClientRect();
const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
} else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
});
const end = e => {
if (!drag || e.pointerId !== pid) return;
if (ghost) {
ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
changed(); dragged = true; setTimeout(() => { dragged = false; });
}
drag = null;
};
root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
function start() {
const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
}
// Keyboard and screen reader mode: the handle is a toggle button (aria-pressed) that picks the item up.
const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); clearOver(); kb = null; changed(); say(msg); };
const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
root.addEventListener('click', e => {
const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) return;
const item = h.closest('[data-dd-item]');
if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); item.parentElement.setAttribute('data-over', ''); h.focus();
say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
});
root.addEventListener('keydown', e => {
if (!kb || e.target !== kbh) return;
if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
e.preventDefault();
const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
if (side && ls.length > 1) {
const t = ls[ls.indexOf(l) + d];
if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
return move(() => t.insertBefore(kb, items(t)[i] || null));
}
const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
});
root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
changed();
});
Tailwind 4 code
Edit on CodePen ↗
StackBlitz ↗
Requires: Tailwind CSS 4
HTML
Copy
<div class="text-gx-ink"><span class="mb-3 block text-[.72rem] tracking-[.12em] text-gx-muted uppercase">Handle styles</span>
<p class="mb-2.5 text-[.8rem] text-gx-muted" id="gx-hint1">Drag a row, or focus its handle, press Space and use the arrow keys.</p>
<div class="grid grid-cols-[repeat(auto-fit,minmax(190px,1fr))] gap-3">
<section class="rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd aria-labelledby="gx-dots-t"><h3 class="text-[.88rem] font-bold" id="gx-dots-t">Dot grip</h3><p class="mt-0.5 mb-2.5 text-[.78rem] text-gx-muted">The familiar six-dot grip on the left.</p>
<ul class="grid grid-cols-[minmax(0,1fr)] gap-1.5 *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" data-dd-list="dots" aria-label="Playlist"><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Northern Lights</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Paper Planes</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Slow Tide</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd aria-labelledby="gx-bars-t"><h3 class="text-[.88rem] font-bold" id="gx-bars-t">Bars on the right</h3><p class="mt-0.5 mb-2.5 text-[.78rem] text-gx-muted">Keeps the start of each row for content.</p>
<ul class="grid grid-cols-[minmax(0,1fr)] gap-1.5 *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" data-dd-list="bars" aria-label="Checkout steps"><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-1 pl-3 text-[.86rem] font-semibold select-none" data-dd-item><span class="min-w-0 flex-1 truncate" data-dd-name>Shipping</span><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-1 pl-3 text-[.86rem] font-semibold select-none" data-dd-item><span class="min-w-0 flex-1 truncate" data-dd-name>Payment</span><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-1 pl-3 text-[.86rem] font-semibold select-none" data-dd-item><span class="min-w-0 flex-1 truncate" data-dd-name>Review</span><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 6h14v2H3zM3 12h14v2H3z"/></svg></button></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd aria-labelledby="gx-strip-t"><h3 class="text-[.88rem] font-bold" id="gx-strip-t">Edge strip</h3><p class="mt-0.5 mb-2.5 text-[.78rem] text-gx-muted">A full-height strip: a big target for touch.</p>
<ul class="grid grid-cols-[minmax(0,1fr)] gap-1.5 *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" data-dd-list="strip" aria-label="Homepage blocks"><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white overflow-hidden pr-2.5 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid w-[22px] flex-none cursor-grab touch-none place-items-center self-stretch border-r border-[#f6cfe0] bg-gx-tint text-gx-accent hover:bg-[#fbe3ee] focus-visible:outline-2 focus-visible:-outline-offset-3 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Hero</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white overflow-hidden pr-2.5 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid w-[22px] flex-none cursor-grab touch-none place-items-center self-stretch border-r border-[#f6cfe0] bg-gx-tint text-gx-accent hover:bg-[#fbe3ee] focus-visible:outline-2 focus-visible:-outline-offset-3 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Customer logos</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white overflow-hidden pr-2.5 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid w-[22px] flex-none cursor-grab touch-none place-items-center self-stretch border-r border-[#f6cfe0] bg-gx-tint text-gx-accent hover:bg-[#fbe3ee] focus-visible:outline-2 focus-visible:-outline-offset-3 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="8" cy="4" r="1.4"/><circle cx="12" cy="4" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12" cy="8" r="1.4"/><circle cx="8" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="8" cy="16" r="1.4"/><circle cx="12" cy="16" r="1.4"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Pricing</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd data-dd-handle-only aria-labelledby="gx-move-t"><h3 class="text-[.88rem] font-bold" id="gx-move-t">Handle only</h3><p class="mt-0.5 mb-2.5 text-[.78rem] text-gx-muted">Rows ignore the pointer, so text stays selectable.</p>
<ul class="grid grid-cols-[minmax(0,1fr)] gap-1.5 *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" data-dd-list="move" aria-label="Signature lines"><li class="flex min-h-10 cursor-auto items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-text" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-move touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent border border-[#d0d5dd]" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Mara Jensen</span></li><li class="flex min-h-10 cursor-auto items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-text" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-move touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent border border-[#d0d5dd]" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Head of Design</span></li><li class="flex min-h-10 cursor-auto items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-text" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-move touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent border border-[#d0d5dd]" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M10 2 7 5h2v4H5V7L2 10l3 3v-2h4v4H7l3 3 3-3h-2v-4h4v2l3-3-3-3v2h-4V5h2z"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>[email protected] </span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
</div></div>
<div class="text-gx-ink"><span class="mb-3 block text-[.72rem] tracking-[.12em] text-gx-muted uppercase">Placeholder styles</span>
<div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-3">
<section class="rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd aria-labelledby="gx-slot-t"><h3 class="text-[.88rem] font-bold" id="gx-slot-t">Dashed slot</h3><p class="mt-0.5 mb-2.5 text-[.78rem] text-gx-muted">Holds the exact size of the item being moved.</p>
<span class="mb-1.5 inline-block text-[.66rem] font-extrabold tracking-[.06em] text-gx-muted uppercase" aria-hidden="true">Mid-drag</span><div class="relative mb-2.5 grid gap-1.5 rounded-[10px] border border-gx-line bg-[#f8f9fb] px-2.5 pt-2.5 pb-[54px] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible" aria-hidden="true"><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none"><span>Intro video</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none" data-dragging><span>Lesson 1: Setup</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none"><span>Lesson 2: Layout</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none absolute right-[-6px] bottom-2.5 left-[34px] -rotate-[1.5deg] shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:rotate-0"><span>Lesson 1: Setup</span></div></div><span class="mb-1.5 inline-block text-[.66rem] font-extrabold tracking-[.06em] text-gx-muted uppercase" aria-hidden="true">Try it</span>
<ul class="grid grid-cols-[minmax(0,1fr)] gap-1.5 *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" data-dd-list="slot" aria-label="Course outline, dashed slot"><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Intro video</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Lesson 1: Setup</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd aria-labelledby="gx-line-t"><h3 class="text-[.88rem] font-bold" id="gx-line-t">Insertion line</h3><p class="mt-0.5 mb-2.5 text-[.78rem] text-gx-muted">Collapses to a line, so the list barely shifts.</p>
<span class="mb-1.5 inline-block text-[.66rem] font-extrabold tracking-[.06em] text-gx-muted uppercase" aria-hidden="true">Mid-drag</span><div class="relative mb-2.5 grid gap-1.5 rounded-[10px] border border-gx-line bg-[#f8f9fb] px-2.5 pt-2.5 pb-[54px] *:data-dragging:relative *:data-dragging:my-[3px] *:data-dragging:h-1 *:data-dragging:min-h-0 *:data-dragging:overflow-visible *:data-dragging:rounded-sm *:data-dragging:border-0 *:data-dragging:bg-gx-accent *:data-dragging:p-0 [&>[data-dragging]>*]:hidden *:data-dragging:before:absolute *:data-dragging:before:-top-1 *:data-dragging:before:-left-0.5 *:data-dragging:before:size-3 *:data-dragging:before:rounded-full *:data-dragging:before:border-3 *:data-dragging:before:border-gx-accent *:data-dragging:before:bg-white *:data-dragging:before:content-['']" aria-hidden="true"><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none"><span>Intro video</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none" data-dragging><span>Lesson 1: Setup</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none"><span>Lesson 2: Layout</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none absolute right-[-6px] bottom-2.5 left-[34px] -rotate-[1.5deg] shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:rotate-0"><span>Lesson 1: Setup</span></div></div><span class="mb-1.5 inline-block text-[.66rem] font-extrabold tracking-[.06em] text-gx-muted uppercase" aria-hidden="true">Try it</span>
<ul class="grid grid-cols-[minmax(0,1fr)] gap-1.5 *:data-dragging:relative *:data-dragging:my-[3px] *:data-dragging:h-1 *:data-dragging:min-h-0 *:data-dragging:overflow-visible *:data-dragging:rounded-sm *:data-dragging:border-0 *:data-dragging:bg-gx-accent *:data-dragging:p-0 [&>[data-dragging]>*]:hidden *:data-dragging:before:absolute *:data-dragging:before:-top-1 *:data-dragging:before:-left-0.5 *:data-dragging:before:size-3 *:data-dragging:before:rounded-full *:data-dragging:before:border-3 *:data-dragging:before:border-gx-accent *:data-dragging:before:bg-white *:data-dragging:before:content-[''] *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" data-dd-list="line" aria-label="Course outline, insertion line"><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Intro video</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Lesson 1: Setup</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
<section class="rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd aria-labelledby="gx-fade-t"><h3 class="text-[.88rem] font-bold" id="gx-fade-t">Faded original</h3><p class="mt-0.5 mb-2.5 text-[.78rem] text-gx-muted">Leaves the item in place at low opacity.</p>
<span class="mb-1.5 inline-block text-[.66rem] font-extrabold tracking-[.06em] text-gx-muted uppercase" aria-hidden="true">Mid-drag</span><div class="relative mb-2.5 grid gap-1.5 rounded-[10px] border border-gx-line bg-[#f8f9fb] px-2.5 pt-2.5 pb-[54px] *:data-dragging:border-dashed *:data-dragging:border-[#98a2b3] *:data-dragging:opacity-60" aria-hidden="true"><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none"><span>Intro video</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none" data-dragging><span>Lesson 1: Setup</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none"><span>Lesson 2: Layout</span></div><div class="flex min-h-10 cursor-default items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-3 text-[.86rem] font-semibold select-none absolute right-[-6px] bottom-2.5 left-[34px] -rotate-[1.5deg] shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:rotate-0"><span>Lesson 1: Setup</span></div></div><span class="mb-1.5 inline-block text-[.66rem] font-extrabold tracking-[.06em] text-gx-muted uppercase" aria-hidden="true">Try it</span>
<ul class="grid grid-cols-[minmax(0,1fr)] gap-1.5 *:data-dragging:border-dashed *:data-dragging:border-[#98a2b3] *:data-dragging:opacity-60 *:data-dd-ghost:-rotate-1 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" data-dd-list="fade" aria-label="Course outline, faded original"><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Intro video</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Lesson 1: Setup</span></li><li class="flex min-h-10 cursor-grab items-center gap-2 rounded-[10px] border border-gx-line bg-white py-1 pr-2.5 pl-1 text-[.86rem] font-semibold select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint1"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><span class="min-w-0 flex-1 truncate" data-dd-name>Lesson 2: Layout</span></li></ul>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p></section>
</div></div>
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-gx-muted uppercase">Drop zone states</span>
<section class="group/gx text-gx-ink rounded-[14px] border border-gx-line bg-white p-3.5 shadow-[0_1px_2px_rgba(16,24,40,.05)]" data-dd="wrap" aria-labelledby="gx-z-t">
<h3 id="gx-z-t" class="sr-only">Drop zone states</h3>
<div class="mb-3.5 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-2.5">
<div class="grid min-h-[86px] content-start gap-0.5 rounded-xl border px-3 py-2.5 text-[.78rem] text-gx-muted outline-2 -outline-offset-2 outline-dashed transition-colors motion-reduce:transition-none border-gx-line bg-[#f8f9fb] outline-transparent"><b class="text-[.84rem] text-gx-ink">Idle</b>No drag in progress.</div>
<div class="grid min-h-[86px] content-start gap-0.5 rounded-xl border px-3 py-2.5 text-[.78rem] text-gx-muted outline-2 -outline-offset-2 outline-dashed transition-colors motion-reduce:transition-none border-transparent bg-[#f8f9fb] outline-[#98a2b3]"><b class="text-[.84rem] text-gx-ink">Ready</b>A drag started: every valid target shows a dashed outline.</div>
<div class="grid min-h-[86px] content-start gap-0.5 rounded-xl border px-3 py-2.5 text-[.78rem] text-gx-muted outline-2 -outline-offset-2 outline-dashed transition-colors motion-reduce:transition-none border-transparent bg-gx-tint outline-solid outline-gx-accent"><b class="text-[.84rem] text-gx-ink">Over</b>The item is above this target and will land here.</div>
<div class="grid min-h-[86px] content-start gap-0.5 rounded-xl border px-3 py-2.5 text-[.78rem] text-gx-muted outline-2 -outline-offset-2 outline-dashed transition-colors motion-reduce:transition-none border-transparent bg-[#fef3f2] outline-solid outline-gx-danger [&_b]:text-gx-danger"><b class="text-[.84rem] text-gx-ink">Full</b>The target is at its limit and refuses the item.</div>
</div>
<p class="mb-2.5 text-[.8rem] text-gx-muted" id="gx-hint3">Drag a file into a folder, or focus its handle, press Space and use Left and Right. Shared holds 2 files.</p>
<div class="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2.5">
<div class="grid min-h-[86px] content-start gap-0.5 rounded-xl border px-3 py-2.5 text-[.78rem] text-gx-muted outline-2 -outline-offset-2 outline-dashed transition-colors motion-reduce:transition-none border-gx-line bg-[#f8f9fb] outline-transparent group-data-[dd-active]/gx:border-transparent group-data-[dd-active]/gx:outline-[#98a2b3] has-[>[data-over]]:border-transparent has-[>[data-over]]:bg-gx-tint has-[>[data-over]]:outline-solid has-[>[data-over]]:outline-gx-accent! [&:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed]))]:bg-[#fef3f2] [&:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed]))]:outline-gx-danger!" data-dd-zone><div class="mb-1.5 flex justify-between gap-2 text-[.84rem] font-bold text-gx-ink">Inbox<span class="text-[.75rem] font-normal text-gx-muted tabular-nums"><span data-dd-count="gx-inbox">3</span></span></div><ul class="flex min-h-10 flex-wrap content-start gap-1.5 data-empty:after:self-center data-empty:after:text-[.78rem] data-empty:after:text-gx-muted data-empty:after:content-['Empty_folder'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-2 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" id="gx-inbox" data-dd-list="files" aria-label="Inbox"><li class="flex cursor-grab items-center gap-1 rounded-lg border border-[#d0d5dd] bg-white py-0.5 pr-2.5 pl-0.5 text-[.8rem] font-semibold text-gx-ink select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="size-4 text-gx-accent" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>brief-v3.pdf</span></li><li class="flex cursor-grab items-center gap-1 rounded-lg border border-[#d0d5dd] bg-white py-0.5 pr-2.5 pl-0.5 text-[.8rem] font-semibold text-gx-ink select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="size-4 text-gx-accent" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>logo.svg</span></li><li class="flex cursor-grab items-center gap-1 rounded-lg border border-[#d0d5dd] bg-white py-0.5 pr-2.5 pl-0.5 text-[.8rem] font-semibold text-gx-ink select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="size-4 text-gx-accent" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>meeting-notes.md</span></li></ul></div>
<div class="grid min-h-[86px] content-start gap-0.5 rounded-xl border px-3 py-2.5 text-[.78rem] text-gx-muted outline-2 -outline-offset-2 outline-dashed transition-colors motion-reduce:transition-none border-gx-line bg-[#f8f9fb] outline-transparent group-data-[dd-active]/gx:border-transparent group-data-[dd-active]/gx:outline-[#98a2b3] has-[>[data-over]]:border-transparent has-[>[data-over]]:bg-gx-tint has-[>[data-over]]:outline-solid has-[>[data-over]]:outline-gx-accent! [&:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed]))]:bg-[#fef3f2] [&:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed]))]:outline-gx-danger!" data-dd-zone><div class="mb-1.5 flex justify-between gap-2 text-[.84rem] font-bold text-gx-ink">Archive<span class="text-[.75rem] font-normal text-gx-muted tabular-nums"><span data-dd-count="gx-archive">0</span></span></div><ul class="flex min-h-10 flex-wrap content-start gap-1.5 data-empty:after:self-center data-empty:after:text-[.78rem] data-empty:after:text-gx-muted data-empty:after:content-['Empty_folder'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-2 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" id="gx-archive" data-dd-list="files" aria-label="Archive"></ul></div>
<div class="grid min-h-[86px] content-start gap-0.5 rounded-xl border px-3 py-2.5 text-[.78rem] text-gx-muted outline-2 -outline-offset-2 outline-dashed transition-colors motion-reduce:transition-none border-gx-line bg-[#f8f9fb] outline-transparent group-data-[dd-active]/gx:border-transparent group-data-[dd-active]/gx:outline-[#98a2b3] has-[>[data-over]]:border-transparent has-[>[data-over]]:bg-gx-tint has-[>[data-over]]:outline-solid has-[>[data-over]]:outline-gx-accent! [&:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed]))]:bg-[#fef3f2] [&:has(>[data-over][data-full]):not(:has([data-dragging],[data-grabbed]))]:outline-gx-danger!" data-dd-zone><div class="mb-1.5 flex justify-between gap-2 text-[.84rem] font-bold text-gx-ink">Shared<span class="text-[.75rem] font-normal text-gx-muted tabular-nums"><span data-dd-count="gx-shared">1</span> / 2</span></div><ul class="flex min-h-10 flex-wrap content-start gap-1.5 data-empty:after:self-center data-empty:after:text-[.78rem] data-empty:after:text-gx-muted data-empty:after:content-['Empty_folder'] *:data-dragging:border-2 *:data-dragging:border-dashed *:data-dragging:border-[#e68bb4] *:data-dragging:bg-gx-tint [&>[data-dragging]>*]:invisible *:data-dd-ghost:-rotate-2 *:data-dd-ghost:shadow-[0_14px_30px_-10px_rgba(16,24,40,.4)] motion-reduce:*:data-dd-ghost:rotate-0 *:data-grabbed:border-gx-accent *:data-grabbed:shadow-[0_0_0_2px_#be185d]" id="gx-shared" data-dd-list="files" data-dd-max="2" aria-label="Shared"><li class="flex cursor-grab items-center gap-1 rounded-lg border border-[#d0d5dd] bg-white py-0.5 pr-2.5 pl-0.5 text-[.8rem] font-semibold text-gx-ink select-none" data-dd-item><button class="grid h-[30px] w-7 flex-none cursor-grab touch-none place-items-center rounded-[7px] text-[#667085] hover:bg-[#f2f4f7] hover:text-gx-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gx-accent" type="button" data-dd-handle aria-describedby="gx-hint3"><svg class="size-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><circle cx="7" cy="5" r="1.5"/><circle cx="13" cy="5" r="1.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="10" r="1.5"/><circle cx="7" cy="15" r="1.5"/><circle cx="13" cy="15" r="1.5"/></svg></button><svg class="size-4 text-gx-accent" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" focusable="false"><path d="M5 2.5h6.5L15 6v11.5H5zM11 2.5V6.5h4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span data-dd-name>budget.xlsx</span></li></ul></div>
</div>
<p class="sr-only" aria-live="assertive" aria-atomic="true" data-dd-live></p>
</section></div>
CSS
Copy
@theme {
--color-gx-accent: #be185d;
--color-gx-tint: #fdf0f6;
--color-gx-line: #e4e7ec;
--color-gx-muted: #5d6679;
--color-gx-ink: #101828;
--color-gx-danger: #b42318;
}
JavaScript
Copy
// Drag and drop with pointer events (mouse, touch and pen) plus a keyboard mode on every handle:
// Space or Enter picks an item up, the arrow keys move it, Space or Enter drops it, Escape puts it back.
// Every move is announced in the component's aria-live region. Markup contract: [data-dd] root,
// [data-dd-list="group"] lists, [data-dd-item] items with a [data-dd-name] and a [data-dd-handle] button.
// data-dd="grid" or "wrap" compares the pointer horizontally; in a grid, Up and Down jump a whole row.
document.querySelectorAll('[data-dd]').forEach(root => {
const grid = root.dataset.dd === 'grid', horiz = grid || root.dataset.dd === 'wrap', live = root.querySelector('[data-dd-live]');
const items = l => [...l.children].filter(c => c.hasAttribute('data-dd-item'));
const lists = l => [...root.querySelectorAll(`[data-dd-list="${l.dataset.ddList}"]`)];
const name = el => el.querySelector('[data-dd-name]').textContent.trim();
const full = l => !!l.dataset.ddMax && items(l).length >= +l.dataset.ddMax;
const pos = el => { const l = el.parentElement, all = items(l);
return (lists(l).length > 1 ? l.getAttribute('aria-label') + ', ' : '') + 'position ' + (all.indexOf(el) + 1) + ' of ' + all.length; };
const say = msg => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };
const clearOver = keep => root.querySelectorAll('[data-over]').forEach(l => l !== keep && l.removeAttribute('data-over'));
const marks = () => root.querySelectorAll('[data-dd-list]').forEach(l => { const n = items(l).length, c = root.querySelector(`[data-dd-count="${l.id}"]`);
l.toggleAttribute('data-empty', !n); l.toggleAttribute('data-full', full(l)); if (c) c.textContent = n; });
const changed = () => {
marks();
root.querySelectorAll('[data-dd-item]').forEach(it => it.querySelector('[data-dd-handle]').setAttribute('aria-label', 'Move ' + name(it)));
root.dispatchEvent(new Event('dd:change', { bubbles: true }));
};
let drag, ghost, home, kb, kbh, sx, sy, ox, oy, pid, dragged, moving;
const putBack = el => home[0].insertBefore(el, home[1] && home[1].parentNode === home[0] ? home[1] : null);
root.addEventListener('pointerdown', e => {
const item = e.target.closest('[data-dd-item]'), handle = e.target.closest('[data-dd-handle]');
if (!item || e.button > 0 || kb) return;
if (!handle && (e.pointerType !== 'mouse' || 'ddHandleOnly' in root.dataset || e.target.closest('a,button,input,select,textarea,label'))) return;
e.preventDefault(); drag = item; pid = e.pointerId; sx = e.clientX; sy = e.clientY; ghost = null;
});
root.addEventListener('pointermove', e => {
if (!drag || e.pointerId !== pid) return;
if (!ghost) { if (Math.hypot(e.clientX - sx, e.clientY - sy) < 6) return; start(); root.setPointerCapture(pid); }
ghost.style.translate = `${e.clientX - ox}px ${e.clientY - oy}px`;
if (e.clientY < 40) scrollBy(0, -12); else if (e.clientY > innerHeight - 40) scrollBy(0, 12);
const hit = document.elementFromPoint(e.clientX, e.clientY), sel = `[data-dd-list="${home[0].dataset.ddList}"]`;
const list = hit && (hit.closest(sel) || hit.closest('[data-dd-zone]')?.querySelector(sel)); // a [data-dd-zone] wrapper widens the target
clearOver(list);
if (!list || !root.contains(list) || drag.contains(list)) return;
list.setAttribute('data-over', '');
if (list !== drag.parentElement && full(list)) return;
const over = items(list).find(c => c !== drag && c.contains(hit));
if (over) { const r = over.getBoundingClientRect();
const ref = (horiz ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2) ? over.nextSibling : over;
if (ref !== drag && ref !== drag.nextSibling) { list.insertBefore(drag, ref); marks(); }
} else if (list !== drag.parentElement && !drag.contains(hit)) { list.append(drag); marks(); }
});
const end = e => {
if (!drag || e.pointerId !== pid) return;
if (ghost) {
ghost.remove(); drag.removeAttribute('data-dragging'); root.removeAttribute('data-dd-active'); document.body.style.cursor = ''; clearOver();
if (e.type === 'pointercancel') putBack(drag); else say(name(drag) + ' moved to ' + pos(drag) + '.');
changed(); dragged = true; setTimeout(() => { dragged = false; });
}
drag = null;
};
root.addEventListener('pointerup', end); root.addEventListener('pointercancel', end);
function start() {
const r = drag.getBoundingClientRect(); ox = sx - r.left; oy = sy - r.top; home = [drag.parentElement, drag.nextSibling];
ghost = drag.cloneNode(true); ghost.removeAttribute('data-dd-item'); ghost.setAttribute('data-dd-ghost', ''); ghost.setAttribute('aria-hidden', 'true'); ghost.inert = true;
ghost.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
Object.assign(ghost.style, { position: 'fixed', left: 0, top: 0, width: r.width + 'px', height: r.height + 'px', margin: 0, zIndex: 999, pointerEvents: 'none' });
drag.after(ghost); drag.setAttribute('data-dragging', ''); root.setAttribute('data-dd-active', ''); document.body.style.cursor = 'grabbing';
}
// Keyboard and screen reader mode: the handle is a toggle button (aria-pressed) that picks the item up.
const finish = msg => { kb.removeAttribute('data-grabbed'); kbh.setAttribute('aria-pressed', 'false'); root.removeAttribute('data-dd-active'); clearOver(); kb = null; changed(); say(msg); };
const move = fn => { moving = true; fn(); kbh.focus(); moving = false; kbh.scrollIntoView({ block: 'nearest' }); clearOver(kb.parentElement); kb.parentElement.setAttribute('data-over', ''); changed(); say(name(kb) + ' moved to ' + pos(kb) + '.'); };
root.addEventListener('click', e => {
const h = e.target.closest('[data-dd-handle]'); if (!h || dragged) return;
const item = h.closest('[data-dd-item]');
if (kb === item) return finish(name(item) + ' dropped, ' + pos(item) + '.');
if (kb) finish(name(kb) + ' dropped, ' + pos(kb) + '.');
kb = item; kbh = h; home = [item.parentElement, item.nextSibling];
item.setAttribute('data-grabbed', ''); h.setAttribute('aria-pressed', 'true'); root.setAttribute('data-dd-active', ''); item.parentElement.setAttribute('data-over', ''); h.focus();
say(name(item) + ' picked up, ' + pos(item) + '. Use the arrow keys to move it, Space to drop it, Escape to cancel.');
});
root.addEventListener('keydown', e => {
if (!kb || e.target !== kbh) return;
if (e.key === 'Escape') { e.preventDefault(); moving = true; putBack(kb); kbh.focus(); moving = false; return finish('Move cancelled. ' + name(kb) + ' returned to ' + pos(kb) + '.'); }
const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key]; if (!d) return;
e.preventDefault();
const l = kb.parentElement, all = items(l), i = all.indexOf(kb), ls = lists(l), side = e.key === 'ArrowLeft' || e.key === 'ArrowRight';
if (side && ls.length > 1) {
const t = ls[ls.indexOf(l) + d];
if (!t) return say(name(kb) + ' is already in ' + l.getAttribute('aria-label') + ', the ' + (d < 0 ? 'first' : 'last') + ' list.');
if (full(t)) return say(t.getAttribute('aria-label') + ' is full.');
return move(() => t.insertBefore(kb, items(t)[i] || null));
}
const step = grid && !side ? d * all.filter(x => x.offsetTop === all[0].offsetTop).length : d, j = Math.max(0, Math.min(all.length - 1, i + step));
if (j === i) return say(name(kb) + ' is already ' + (d < 0 ? 'first' : 'last') + '.');
move(() => l.insertBefore(kb, j > i ? all[j].nextSibling : all[j]));
});
root.addEventListener('focusout', e => { if (kb && !moving && e.target === kbh) finish(name(kb) + ' dropped, ' + pos(kb) + '.'); });
changed();
});