Drag & Drop

Drag & drop is a very common effect in web page. Developers usually apply it for sorting purpose. Days without it needs to listen mousedown / mousemove / mouseup events and do lots of stuff to simulate drap & drop effect. Now developers could apply it in modern way.

Try to drag & drop the following example to change units sorting.

  • custom category 1
  • custom category 2
  • custom category 3
  • custom category 4
  • custom category 5
  • custom category 6

custom category 1

Instantiation

Developers could copy / paste the following styleHTML and JavaScript into your web page and check the effect.

style

<style> .dnd-wrap { --row-block-size: 3em; --icon: path('M12 2l-5.5 9h11L12 2zm0 3.84L13.93 9h-3.87L12 5.84zM17.5 13c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5zm0 7c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5zM3 21.5h8v-8H3v8zm2-6h4v4H5v-4z'); --text-color-nomal: rgba(31 31 31); --text-color-active: rgba(31 31 31); --text-color: var(--text-color-nomal); --background-color-normal: rgba(255 255 255); --background-color-active: rgba(233 240 253); --background-color: var(--background-color-normal); --indicator-color: rgba(83 131 236); --drop-image-text-color: rgba(255 255 255); --drop-image-background-color: rgba(59 103 206); --wrap-shadow-color: rgba(0 0 0/.2); inline-size: min(100%, 960px); margin: 3em auto; overflow: hidden; border-radius: .75em; box-shadow: 0 2px 6px var(--wrap-shadow-color); @media (prefers-color-scheme: dark) { --text-color-nomal: rgba(227 227 227); --text-color-active: rgba(61 64 67); --background-color-normal: rgba(41 42 45); --background-color-active: rgba(147 179 242); --indicator-color: rgba(147 179 242); --drop-image-text-color: rgba(61 64 67); --drop-image-background-color: rgba(147 179 242); --wrap-shadow-color: rgba(0 0 0/.5); } .dnd-wrap__ul { inline-size: 100%; display: block; .dnd-wrap__ul__li { --indicator-opacity-normal: 0; --indicator-opacity-active: 1; --indicator-opacity: var(--indicator-opacity-normal); --indicator-inset-block: auto 0; position: relative; color: var(--text-color); background-color: var(--background-color); font-size: 1em; block-size: var(--row-block-size); padding-inline: 1em; display: flex; gap: .5em; align-items: center; outline: 0 none; will-change: background-color, color; transition: background-color 100ms ease, color 100ms ease; &::before { inline-size: 1.5em; aspect-ratio: 1/1; content: ''; background-color: var(--text-color); clip-path: var(--icon); } &:focus-within { --text-color: var(--text-color-active); --background-color: var(--background-color-active); } .dnd-wrap__ul__li__indicator { position: absolute; inset-inline-start: 0; inset-block: var(--indicator-inset-block); inline-size: 100%; block-size: 2px; background-color: var(--indicator-color); display: block; opacity: var(--indicator-opacity); pointer-events: none; &::before { position: absolute; content: ''; inset-inline-start: 0; inset-block-start: -3px; inline-size: 0; block-size: 0; border: 4px solid transparent; border-inline-start-color: var(--indicator-color); } &::after { position: absolute; content: ''; inset-inline-end: 0; inset-block-start: -3px; inline-size: 0; block-size: 0; border: 4px solid transparent; border-inline-end-color: var(--indicator-color); } } &[data-indicator] .dnd-wrap__ul__li__indicator { --indicator-opacity: var(--indicator-opacity-active); z-index: 1; } &[data-indicator=start] { --indicator-inset-block: 0 auto; } &[data-indicator=end] { --indicator-inset-block: auto 0; } } } .dnd-drop-image { position: absolute; inset-inline-start: -200px; inset-block-start: calc(var(--row-block-size) * -1); inline-size: 200px; color: var(--drop-image-text-color); background-color: var(--drop-image-background-color); block-size: var(--row-block-size); border-radius: var(--row-block-size); padding-inline: 1em; box-sizing: border-box; display: flex; gap: .5em; align-items: center; p { flex-grow: 1; min-inline-size: 0; } &::before { flex-shrink: 0; inline-size: 1.5em; aspect-ratio: 1/1; content: ''; background-color: var(--drop-image-text-color); clip-path: var(--icon); } } } </style>

HTML

<div class="dnd-wrap"> <ul class="dnd-wrap__ul"> <li class="dnd-wrap__ul__li" draggable="true" tabindex="0"> custom category 1 <em class="dnd-wrap__ul__li__indicator"></em> </li> <li class="dnd-wrap__ul__li" draggable="true" tabindex="0"> custom category 2 <em class="dnd-wrap__ul__li__indicator"></em> </li> <li class="dnd-wrap__ul__li" draggable="true" tabindex="0"> custom category 3 <em class="dnd-wrap__ul__li__indicator"></em> </li> <li class="dnd-wrap__ul__li" draggable="true" tabindex="0"> custom category 4 <em class="dnd-wrap__ul__li__indicator"></em> </li> <li class="dnd-wrap__ul__li" draggable="true" tabindex="0"> custom category 5 <em class="dnd-wrap__ul__li__indicator"></em> </li> <li class="dnd-wrap__ul__li" draggable="true" tabindex="0"> custom category 6 <em class="dnd-wrap__ul__li__indicator"></em> </li> </ul> <div class="dnd-drop-image"> <p class="text-overflow">custom category 1</p> </div> </div>

JavaScript

<script type="module"> const wrap = document.querySelector('.dnd-wrap'); const ul = document.querySelector('.dnd-wrap__ul'); const dragImage = document.querySelector('.dnd-drop-image'); const dragImageP = dragImage.querySelector('p'); let units = Array.from(document.querySelectorAll('.dnd-wrap li')); let dragInfo = { target: '', index: 0 }; const clearIndicator = () => { Array.from(wrap.querySelectorAll('[data-indicator]')) .forEach(unit => unit.toggleAttribute('data-indicator', false)); }; const onDnD = (evt) => { const { type, target } = evt; switch (type) { case 'dragstart': { const content = target.textContent.trim(); dragImageP.textContent = content; evt.dataTransfer.setData('text/plain', content); evt.dataTransfer.setDragImage(dragImage, 100, 40); dragInfo.target = target; dragInfo.index = units.indexOf(target); break; } case 'dragover': { evt.preventDefault(); evt.dataTransfer.dropEffect = 'move'; clearIndicator(); const hoverUnit = evt.target.closest('.dnd-wrap__ul__li'); if (!hoverUnit || hoverUnit === dragInfo.target) { return; } const index = units.indexOf(hoverUnit); hoverUnit.dataset.indicator = index > dragInfo.index ? 'end' : 'start'; break; } case 'dragend': case 'drop': { evt.preventDefault(); const hoverUnit = wrap.querySelector('[data-indicator]'); if (hoverUnit) { const indicator = hoverUnit.dataset.indicator; const index = units.indexOf(hoverUnit); if (indicator === 'start') { if (index === 0) { ul.insertBefore(dragInfo.target, units[0]); } else { ul.insertBefore(dragInfo.target, units[index]); } } else { if (index + 1 === units.length) { ul.appendChild(dragInfo.target); } else { ul.insertBefore(dragInfo.target, units[index + 1]); } } } clearIndicator(); dragInfo.target.focus(); units = Array.from(document.querySelectorAll('.dnd-wrap li')); break; } } }; // events Array.from(document.querySelectorAll('.dnd-wrap li')).forEach( (unit) => { unit.addEventListener('dragstart', onDnD); unit.addEventListener('dragend', onDnD); } ); wrap.addEventListener('dragover', onDnD); wrap.addEventListener('drop', onDnD); </script>

Elements for drag

Each drag unit needs to add attribute draggable="true" and listener dragstart events. To make Safari active, developers also need to call event.dataTransfer.setData to set some data.

<div class="drag-unit" draggable="true"> drag me </div> <script type="module"> const unit = document.querySelector('.drag-unit'); unit.addEventListener('dragstart', (event) => { console.log('event: dragstart'); event.dataTransfer.setData('text/plain', 'hello world'); } ); </script>

Developers could also apply image or exist HTML element as customize dragImage. Once you choose exist HTML element as dragImage, you might need to arrange it away viewport to avoid annoy background you don't like.(browser will screenshot the element include background beyond it. The screenshot won't take background if element is not in viewport)

customize dragImage
customize dragImage

<style> .drag-image { position: absolute; inset-inline-start: -1000px; inset-block-start: -1000px; ... ... } </style> <div class="drag-unit" draggable="true"> drag me </div> <div class="drag-image"> drag image </div> <script type="module"> const unit = document.querySelector('.drag-unit'); const dragImage = document.querySelector('.drag-image'); unit.addEventListener('dragstart', (event) => { console.log('event: dragstart'); event.dataTransfer.setData('text/plain', 'hello world'); event.dataTransfer.setDragImage(dragImage, 0, 0); // element, offsetX, offsetY } ); </script>

Drop Zone

Developers need to listen dragover and drop events to make drop zone dropable. Remember call event.preventDefault() to avoid browser default behavior.

<div class="drag-zone"></div> <script type="module"> const dropZone = document.querySelector('.drag-zone'); const onDnD = (event) => { const { target } = event; event.preventDefault(); switch (type) { case 'dragover': // do something when dragover break; case 'drop': // do something when drop break; } }; dropZone.addEventListener('dragover', onDnD); dropZone.addEventListener('drop', onDnD); </script>

Active drag behavior in mobile device

Users need to press and hold on drag unit almost 2 seconds to active drag behavior in mobile browser. This is also the main different desktop & mobile browser.

Reference