Skip to content

Examples

Draggable - Basic

A minimal setup with four draggable elements using pointer and keyboard (motion) sensor. You can drag them all at once too if you have a multi-touch device (e.g. phone or tablet).

ts
import { Draggable, KeyboardMotionSensor, PointerSensor } from 'dragdoll';

let zIndex = 0;

const draggableElements = [...document.querySelectorAll('.draggable')] as HTMLElement[];

draggableElements.forEach((element) => {
  const pointerSensor = new PointerSensor(element);
  const keyboardSensor = new KeyboardMotionSensor(element);
  new Draggable([pointerSensor, keyboardSensor], {
    elements: () => [element],
    onStart: () => {
      element.classList.add('dragging');
      element.style.zIndex = `${++zIndex}`;
    },
    onEnd: () => {
      element.classList.remove('dragging');
    },
  });
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Basic</title>
    <meta
      name="description"
      content="A minimal setup with four draggable elements using pointer and keyboard (motion) sensor. You can drag them all at once too if you have a multi-touch device (e.g. phone or tablet)."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  padding: 10px;
  display: flex;
  flex-flow: row nowrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
  gap: 10px 10px;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
}

@media (width < 430px) {
  .card.draggable {
    width: calc((100% - 50px) / 4);
    aspect-ratio: 1 / 1;
    height: auto;
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Auto Scroll & Transforms

Demonstrates auto-scrolling during drag and transparent CSS transform handling. The draggable element is always guaranteed to move in sync with the active sensor, regardless of any CSS transforms or zoom in the document. Auto-scroll kicks in when you drag near the window edges.

ts
import { autoScrollPlugin, Draggable, KeyboardMotionSensor, PointerSensor } from 'dragdoll';

const element = document.querySelector('.draggable') as HTMLElement;
const dragContainer = document.querySelector('.drag-container') as HTMLElement;
const pointerSensor = new PointerSensor(element);
const keyboardSensor = new KeyboardMotionSensor(element, {
  computeSpeed: () => 100,
});
new Draggable([pointerSensor, keyboardSensor], {
  container: dragContainer,
  elements: () => [element],
  frozenStyles: () => ['left', 'top'],
  onStart: () => {
    element.classList.add('dragging');
  },
  onEnd: () => {
    element.classList.remove('dragging');
  },
}).use(
  autoScrollPlugin({
    targets: [
      {
        element: window,
        axis: 'y',
        padding: { top: Infinity, bottom: Infinity },
      },
    ],
  }),
);
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Auto Scroll &amp; Transforms</title>
    <meta
      name="description"
      content="Demonstrates auto-scrolling during drag and transparent CSS transform handling. The draggable element is always guaranteed to move in sync with the active sensor, regardless of any CSS transforms or zoom in the document. Auto-scroll kicks in when you drag near the window edges."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="drag-container-outer">
      <div class="drag-container"></div>
    </div>
    <div class="card-container-outer">
      <div class="card-container">
        <div class="card draggable" tabindex="0">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
            <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
            <path
              d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
            />
          </svg>
        </div>
      </div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  height: 300%;
  overflow-y: auto;
}

.drag-container-outer {
  position: fixed;
  left: 0;
  top: 0;
  width: 0px;
  height: 0px;
  transform: scale(0.3) skew(10deg, 10deg);
  transform-origin: 17px 37px;
}

.drag-container {
  position: absolute;
  left: 10px;
  top: 10px;
  width: 0px;
  height: 0px;
  transform: scale(0.7) skew(10deg, 10deg);
  transform-origin: 27px 47px;
}

.card-container-outer {
  position: absolute;
  inset: 0;
  transform: scale(1.2);
}

.card-container {
  position: absolute;
  inset: 0;
  transform: scale(0.5) skew(-5deg, -5deg);
}

.card.draggable {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translateX(-50%) translateY(-50%) scale(1.2);
  transform-origin: 50% 50%;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Locked Axis

Here we have two elements which can be dragged on one axis only. You can use this example as the basis of building your own custom position modifiers (a powerful feature that allows you to control a dragged element's position at every step of the drag process).

ts
import { Draggable, KeyboardMotionSensor, PointerSensor } from 'dragdoll';

let zIndex = 0;

const draggableElements = [...document.querySelectorAll('.draggable')] as HTMLElement[];

draggableElements.forEach((element) => {
  const axis = element.classList.contains('axis-x') ? 'x' : 'y';
  const pointerSensor = new PointerSensor(element);
  const keyboardSensor = new KeyboardMotionSensor(element);
  new Draggable([pointerSensor, keyboardSensor], {
    elements: () => [element],
    positionModifiers: [
      (change) => {
        if (axis === 'x') change.y = 0;
        else change.x = 0;
        return change;
      },
    ],
    onStart: () => {
      element.classList.add('dragging');
      element.style.zIndex = `${++zIndex}`;
    },
    onEnd: () => {
      element.classList.remove('dragging');
    },
  });
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Locked Axis</title>
    <meta
      name="description"
      content="Here we have two elements which can be dragged on one axis only. You can use this example as the basis of building your own custom position modifiers (a powerful feature that allows you to control a dragged element's position at every step of the drag process)."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable axis-x" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M406.6 374.6l96-96c12.5-12.5 12.5-32.8 0-45.3l-96-96c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224l-293.5 0 41.4-41.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-96 96c-12.5 12.5-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288l293.5 0-41.4 41.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0z"
        />
      </svg>
    </div>
    <div class="card draggable axis-y" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M182.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-96 96c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L128 109.3l0 293.5L86.6 361.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 402.7l0-293.5 41.4 41.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-96-96z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  display: flex;
  flex-flow: row wrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
  gap: 10px 10px;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;

  &.axis-x {
    cursor: ew-resize;
  }

  &.axis-y {
    cursor: ns-resize;
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Snap To Grid

A simple demo on how to use the built-in snap modifier.

ts
import { createSnapModifier, Draggable, KeyboardSensor, PointerSensor } from 'dragdoll';

const GRID_WIDTH = 40;
const GRID_HEIGHT = 40;

const element = document.querySelector('.draggable') as HTMLElement;
const pointerSensor = new PointerSensor(element);
const keyboardSensor = new KeyboardSensor(element, {
  moveDistance: { x: GRID_WIDTH, y: GRID_HEIGHT },
});
new Draggable([pointerSensor, keyboardSensor], {
  elements: () => [element],

  positionModifiers: [createSnapModifier(GRID_WIDTH, GRID_HEIGHT)],
  onStart: () => {
    element.classList.add('dragging');
  },
  onEnd: () => {
    element.classList.remove('dragging');
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Snap To Grid</title>
    <meta name="description" content="A simple demo on how to use the built-in snap modifier." />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
.card.draggable {
  position: absolute;
  left: 0;
  top: 0;
  width: 80px;
  height: 80px;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Containment

A simple demo on how to use the built-in containment modifier. The first argument of createContainmentModifier should be a function that returns the client rect of the containment area. That function is called on every drag 'move' event and also on 'start' and 'end' events. The second argument is a boolean whose value is cached on start event to define if the modifier should track drifting of the sensor when the dragged element hits an edge of the containment area and the sensor keeps on moving away. If the drift is being tracked the draggable element will not be moved to the opposing direction until the sensor is back inside the containment area. By default the drift is tracked only for PointerSensor.

ts
import {
  createContainmentModifier,
  Draggable,
  KeyboardMotionSensor,
  PointerSensor,
} from 'dragdoll';

const element = document.querySelector('.draggable') as HTMLElement;
const pointerSensor = new PointerSensor(element);
const keyboardSensor = new KeyboardMotionSensor(element);
new Draggable([pointerSensor, keyboardSensor], {
  elements: () => [element],
  positionModifiers: [
    createContainmentModifier(() => {
      return {
        x: 0,
        y: 0,
        width: window.innerWidth,
        height: window.innerHeight,
      };
    }),
  ],
  onStart: () => {
    element.classList.add('dragging');
  },
  onEnd: () => {
    element.classList.remove('dragging');
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Containment</title>
    <meta
      name="description"
      content="A simple demo on how to use the built-in containment modifier. The first argument of `createContainmentModifier` should be a function that returns the client rect of the containment area. That function is called on every drag 'move' event and also on 'start' and 'end' events. The second argument is a boolean whose value is cached on start event to define if the modifier should track drifting of the sensor when the dragged element hits an edge of the containment area and the sensor keeps on moving away. If the drift is being tracked the draggable element will not be moved to the opposing direction until the sensor is back inside the containment area. By default the drift is tracked only for `PointerSensor`."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  display: flex;
  flex-flow: row wrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
  gap: 10px 10px;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Combined Modifiers

Demonstrates grid-aware containment. The element has a distance threshold, snaps to a 40px grid, and is contained within the viewport without partial grid cells at the edges.

ts
import { createContainmentModifier, Draggable, PointerSensor } from 'dragdoll';

const THRESHOLD = 5;
const GRID = 40;

const element = document.querySelector('.draggable') as HTMLElement;
const pointerSensor = new PointerSensor(element);

new Draggable([pointerSensor], {
  elements: () => [element],
  startPredicate: ({ event }) => {
    const dx = event.x - event.startX;
    const dy = event.y - event.startY;
    return Math.sqrt(dx * dx + dy * dy) >= THRESHOLD ? true : undefined;
  },
  positionModifiers: [
    createContainmentModifier(
      () => ({
        x: 0,
        y: 0,
        width: window.innerWidth,
        height: window.innerHeight,
      }),
      { snapX: GRID, snapY: GRID },
    ),
  ],
  onStart: () => {
    element.classList.add('dragging');
  },
  onEnd: () => {
    element.classList.remove('dragging');
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Combined Modifiers</title>
    <meta
      name="description"
      content="Demonstrates grid-aware containment. The element has a distance threshold, snaps to a 40px grid, and is contained within the viewport without partial grid cells at the edges."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
}

.card.draggable {
  position: relative;
  left: 0;
  top: 0;
  width: 80px;
  height: 80px;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Center To Pointer

Here we use a custom position modifier to align the dragged element's center with the pointer sensor's position on drag start.

ts
import { Draggable, KeyboardMotionSensor, PointerSensor } from 'dragdoll';

const element = document.querySelector('.draggable') as HTMLElement;
const pointerSensor = new PointerSensor(element);
const keyboardSensor = new KeyboardMotionSensor(element);
new Draggable([pointerSensor, keyboardSensor], {
  elements: () => [element],
  positionModifiers: [
    (change, { drag, item, phase }) => {
      // Align the dragged element so that the pointer
      // is in the center of the element.
      if (phase === 'start' && drag.sensor instanceof PointerSensor) {
        const { clientRect } = item;
        const { x, y } = drag.startEvent;
        const targetX = clientRect.x + clientRect.width / 2;
        const targetY = clientRect.y + clientRect.height / 2;
        change.x = x - targetX;
        change.y = y - targetY;
      }
      return change;
    },
  ],
  onStart: () => {
    element.classList.add('dragging');
  },
  onEnd: () => {
    element.classList.remove('dragging');
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Center To Pointer</title>
    <meta
      name="description"
      content="Here we use a custom position modifier to align the dragged element's center with the pointer sensor's position on drag start."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  display: flex;
  flex-flow: row wrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
  gap: 10px 10px;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Drag Handle

A simple example on how to create a drag handle. There is no built-in 'handle' option, because it would be too limiting. In this example the PointerSensor is used for the handle element while the KeyboardMotionSensor is used normally for the draggable element. You could also create the KeyboardMotionSensor for the handle element if you wished, it's really up to your preferences. Hopefully this showcases how flexible and customizable DragDoll really is with its sensor system.

ts
import { Draggable, KeyboardMotionSensor, PointerSensor } from 'dragdoll';

const element = document.querySelector('.draggable') as HTMLElement;
const handle = element.querySelector('.handle') as HTMLElement;
const pointerSensor = new PointerSensor(handle);
const keyboardSensor = new KeyboardMotionSensor(element);
const draggable = new Draggable([pointerSensor, keyboardSensor], {
  elements: () => [element],
  onStart: () => {
    element.classList.add('dragging');
    if (draggable.drag!.sensor instanceof PointerSensor) {
      element.classList.add('pointer-dragging');
    } else {
      element.classList.add('keyboard-dragging');
    }
  },
  onEnd: () => {
    element.classList.remove('dragging', 'pointer-dragging', 'keyboard-dragging');
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Drag Handle</title>
    <meta
      name="description"
      content="A simple example on how to create a drag handle. There is no built-in 'handle' option, because it would be too limiting. In this example the `PointerSensor` is used for the handle element while the `KeyboardMotionSensor` is used normally for the draggable element. You could also create the `KeyboardMotionSensor` for the handle element if you wished, it's really up to your preferences. Hopefully this showcases how flexible and customizable DragDoll really is with its sensor system."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <div class="handle">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
          <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path
            d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
          />
        </svg>
      </div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  display: flex;
  flex-flow: row wrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
  gap: 10px 10px;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
  cursor: auto;
  touch-action: auto;

  & .handle {
    touch-action: none;
    display: flex;
    justify-content: safe center;
    align-items: safe center;
    cursor: grab;
    border-radius: 4px;
    background-color: rgba(0, 0, 0, 0.2);
    width: 40px;
    height: 40px;
    position: absolute;
    top: 4px;
    right: 4px;

    .card.pointer-dragging & {
      cursor: grabbing;
    }

    .card.keyboard-dragging & {
      cursor: auto;
    }

    & svg {
      width: 24px;
      height: 24px;
    }

    @media (hover: hover) and (pointer: fine) {
      .card:not(.keyboard-dragging) &:hover,
      .card.pointer-dragging & {
        background-color: rgba(0, 0, 0, 0.3);
      }
    }
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Multiple Elements

Sometimes you might want to drag multiple elements at once and DragDoll provides you an easy way to do that. Just return an array of elements in the elements callback and you're good to go.

ts
import { Draggable, KeyboardMotionSensor, PointerSensor } from 'dragdoll';

const draggableElements = [...document.querySelectorAll('.draggable')] as HTMLElement[];

draggableElements.forEach((element) => {
  const otherElements = draggableElements.filter((el) => el !== element);
  const pointerSensor = new PointerSensor(element);
  const keyboardSensor = new KeyboardMotionSensor(element);
  new Draggable([pointerSensor, keyboardSensor], {
    elements: () => {
      return [element, ...otherElements];
    },
    startPredicate: () => {
      return !element.classList.contains('dragging');
    },
    onStart: (drag) => {
      drag.items.forEach((item) => {
        item.element.classList.add('dragging');
      });
    },
    onEnd: (drag) => {
      drag.items.forEach((item) => {
        item.element.classList.remove('dragging');
      });
    },
  });
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Multiple Elements</title>
    <meta
      name="description"
      content="Sometimes you might want to drag multiple elements at once and DragDoll provides you an easy way to do that. Just return an array of elements in the `elements` callback and you're good to go."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  display: flex;
  flex-flow: row nowrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
  gap: 10px 10px;
  padding: 10px;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
}

@media (width < 430px) {
  .card.draggable {
    width: calc((100% - 50px) / 4);
    aspect-ratio: 1 / 1;
    height: auto;
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Start Threshold

A draggable link element that requires 5px of movement before the drag starts. Clicking the link works normally. When drag starts, the element position is offset so the pointer stays at the original position relative to the element.

ts
import { Draggable, DraggableModifier, PointerSensor, startOffsetModifier } from 'dragdoll';

const THRESHOLD = 5;

let zIndex = 0;

const element = document.querySelector('.draggable') as HTMLElement;
const pointerSensor = new PointerSensor(element);

new Draggable<PointerSensor>([pointerSensor], {
  elements: () => [element],
  // Require 5px of movement before drag starts.
  // This allows clicking the link to work normally.
  startPredicate: ({ event }) => {
    const dx = event.x - event.startX;
    const dy = event.y - event.startY;
    return Math.sqrt(dx * dx + dy * dy) >= THRESHOLD ? true : undefined;
  },
  // Offset position on start to account for the threshold distance moved.
  positionModifiers: [startOffsetModifier as unknown as DraggableModifier<PointerSensor>],
  // preventClickOnEnd is enabled by default, so the link click is
  // automatically blocked after dragging. No manual workaround needed!
  onStart: () => {
    element.classList.add('dragging');
    element.style.zIndex = `${++zIndex}`;
  },
  onEnd: () => {
    element.classList.remove('dragging');
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Start Threshold</title>
    <meta
      name="description"
      content="A draggable link element that requires 5px of movement before the drag starts. Clicking the link works normally. When drag starts, the element position is offset so the pointer stays at the original position relative to the element."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <a
      href="https://muuri.dev"
      class="card draggable"
      target="_blank"
      rel="noopener noreferrer"
      draggable="false"
    >
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C680.8 251.2 170.6 330 220.6 380c56.5 56.5 148 56.5 204.5 0L579.8 267.7zM60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372.1 74 321.1 105.5 289.5L217.7 177.2c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C540.8 260.8 470.6 182 420.6 132c-56.5-56.5-148-56.5-204.5 0L60.2 244.3z"
        />
      </svg>
      <span>muuri.dev</span>
    </a>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  padding: 10px;
  display: flex;
  flex-flow: row nowrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
  flex-direction: column;
  gap: 8px;
  width: 120px;
  height: 120px;
  text-decoration: none;
  font-size: 16px;
  font-weight: 600;

  /* Prevent touch scrolling - REQUIRED for touch dragging */
  touch-action: none;

  /* Prevent tap highlight on mobile */
  -webkit-tap-highlight-color: transparent;

  /* Prevent long-press context menu on mobile */
  -webkit-touch-callout: none;

  & svg {
    width: 2em;
    height: 2em;
  }

  & span {
    color: inherit;
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Touch Delay

A draggable element with a 1 second touch delay. On touch devices, you must hold the element for 1 second before dragging starts, allowing normal scrolling. Mouse and pen input start dragging immediately.

ts
import {
  createTouchDelayPredicate,
  Draggable,
  DraggableModifier,
  PointerSensor,
  startOffsetModifier,
} from 'dragdoll';

const element = document.querySelector('.draggable') as HTMLElement;
const pointerSensor = new PointerSensor(element);

new Draggable<PointerSensor>([pointerSensor], {
  elements: () => [element],
  startPredicate: createTouchDelayPredicate({ touchDelay: 1000 }),
  positionModifiers: [startOffsetModifier as unknown as DraggableModifier<PointerSensor>],
  onStart: () => {
    element.classList.add('dragging');
  },
  onEnd: () => {
    element.classList.remove('dragging');
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Touch Delay</title>
    <meta
      name="description"
      content="A draggable element with a 1 second touch delay. On touch devices, you must hold the element for 1 second before dragging starts, allowing normal scrolling. Mouse and pen input start dragging immediately."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="card draggable" tabindex="0">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
        <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
        <path
          d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
        />
      </svg>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: safe center;
  align-items: safe center;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Drag Preview

A drag preview inside a scrollable container with complex CSS transforms. The drag preview element (a full clone) is reparented to document.body via the container option, escaping overflow clipping and stacking contexts. The core's transform normalization preserves the exact visual shape during drag. Auto-scroll keeps working across the transformed container.

ts
import {
  autoScrollPlugin,
  Draggable,
  getLocalOffset,
  KeyboardMotionSensor,
  PointerSensor,
} from 'dragdoll';

const scrollContainer = document.querySelector('.transform-inner') as HTMLElement;
const cards = document.querySelectorAll<HTMLElement>('.card.draggable');
let zIndex = 0;

cards.forEach((card) => {
  const pointerSensor = new PointerSensor(card);
  const keyboardSensor = new KeyboardMotionSensor(card);
  new Draggable([pointerSensor, keyboardSensor], {
    elements: () => {
      // Clone the card element.
      const clone = card.cloneNode(true) as HTMLElement;

      clone.style.pointerEvents = 'none';
      clone.style.contain = 'layout';
      clone.classList.add('dragging');

      // We don't have to do any position alignment if the element is already
      // positioned absolutely or fixed, the clone should be guaranteed to be
      // visually aligned with the original.
      const clonePos = getComputedStyle(clone).position;
      if (clonePos === 'absolute' || clonePos === 'fixed') {
        card.parentElement!.appendChild(clone);
        return [clone];
      }

      // Set the position of the clone to absolute, and position it at the top
      // left of the parent element.
      clone.style.position = 'absolute';
      clone.style.left = `0px`;
      clone.style.top = `0px`;
      clone.style.margin = '0';

      // Append the clone to the parent element.
      card.parentElement!.appendChild(clone);

      // Compute the offset between the original element and the drag preview.
      const cardRect = card.getBoundingClientRect();
      const cloneOffset = getLocalOffset(clone, cardRect.x, cardRect.y);

      // Position the clone at the computed offset.
      clone.style.left = `${cloneOffset.x}px`;
      clone.style.top = `${cloneOffset.y}px`;

      return [clone];
    },
    container: () => document.body,
    onStart: () => {
      card.classList.add('dragging');
    },
    onEnd: (drag) => {
      const dragItem = drag.items[0];

      // Align the original element to the final viewport position of the drag
      // preview.
      const offset = getLocalOffset(card, dragItem.clientRect.x, dragItem.clientRect.y);
      const parts = (getComputedStyle(card).translate || '').split(' ');
      const x = (parseFloat(parts[0]) || 0) + offset.x;
      const y = (parseFloat(parts[1]) || 0) + offset.y;
      card.style.translate = `${x}px ${y}px`;
      card.style.zIndex = String(++zIndex);

      // Remove the drag preview clone.
      dragItem.element.remove();

      // Remove the dragging class.
      card.classList.remove('dragging');
    },
  }).use(
    autoScrollPlugin({
      targets: () => [
        {
          element: scrollContainer,
          axis: 'y',
          padding: { top: Infinity, bottom: Infinity },
        },
      ],
    }),
  );
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Drag Preview</title>
    <meta
      name="description"
      content="A drag preview inside a scrollable container with complex CSS transforms. The drag preview element (a full clone) is reparented to document.body via the container option, escaping overflow clipping and stacking contexts. The core's transform normalization preserves the exact visual shape during drag. Auto-scroll keeps working across the transformed container."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="transform-outer">
      <div class="transform-inner">
        <div class="scroll-content">
          <div class="card draggable" tabindex="0">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
          <div class="card draggable" tabindex="0">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
          <div class="card draggable" tabindex="0">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
          <div class="card draggable" tabindex="0">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
          <div class="card draggable" tabindex="0">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
          <div class="card draggable" tabindex="0">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
        </div>
      </div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
.transform-outer {
  position: fixed;
  inset: 0;
  transform: scale(0.85) rotate(-2deg);
  transform-origin: center center;
  overflow: hidden;
}

.transform-inner {
  position: absolute;
  inset: 0;
  transform: skew(-3deg, -3deg);
  overflow-y: auto;
}

.scroll-content {
  position: relative;
  min-height: 250%;
  padding: 20px;
  display: flex;
  flex-flow: column nowrap;
  align-items: center;
  gap: 20px;
}

.card.draggable {
  position: relative;
  flex-shrink: 0;
  transform: scale(1.1) rotate(3deg);
  transform-origin: 50% 50%;
}

.scroll-content > .card.draggable.dragging {
  opacity: 0.3;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Draggable - Multi-Item Drag Preview

One draggable moves three items simultaneously via drag preview clones. Each item sits inside its own overflow-hidden container with different complex CSS transforms (scale, skew, rotation). The core's transform normalization ensures every clone matches its original's visual shape after reparenting to document.body.

ts
import { Draggable, getLocalOffset, KeyboardMotionSensor, PointerSensor } from 'dragdoll';

const cards = Array.from(document.querySelectorAll<HTMLElement>('.card.draggable'));

const pointerSensor = new PointerSensor(window, {
  startPredicate: (e) => {
    if ('button' in e && (e as MouseEvent).button > 0) return false;
    const target = e.target as Element | null;
    if (!target) return false;
    return cards.some((card) => card.contains(target));
  },
});

const keyboardSensor = new KeyboardMotionSensor(null, {
  startPredicate: () => {
    const focused = document.activeElement;
    if (!focused) return null;
    const card = cards.find((c) => c.contains(focused));
    if (!card) return null;
    const { left, top } = card.getBoundingClientRect();
    return { x: left, y: top };
  },
});

new Draggable([pointerSensor, keyboardSensor], {
  elements: () => {
    const clones: HTMLElement[] = [];
    const cloneOffsets: ({ x: number; y: number } | undefined)[] = [];

    // Writes: clone each card, set styles, and append to the parent.
    for (let i = 0; i < cards.length; i++) {
      const card = cards[i];

      // Clone the card element.
      const clone = card.cloneNode(true) as HTMLElement;

      // Clone should not be clickable and it should not affect the layout.
      clone.style.pointerEvents = 'none';
      clone.style.contain = 'layout';

      // Add dragging class.
      clone.classList.add('dragging');

      // We don't have to do any position alignment if the element is already
      // positioned absolutely or fixed, the clone should be guaranteed to be
      // visually aligned with the original.
      const clonePos = getComputedStyle(clone).position;
      if (clonePos !== 'absolute' && clonePos !== 'fixed') {
        // Set the position of the clone to absolute, and position it at the
        // top left of the parent element.
        clone.style.position = 'absolute';
        clone.style.left = '0px';
        clone.style.top = '0px';
        clone.style.margin = '0';

        // We need offsets only for non-absolute/fixed positioned elements.
        cloneOffsets[i] = { x: 0, y: 0 };
      }

      // Append the clone to the parent element.
      card.parentElement!.appendChild(clone);
      clones[i] = clone;
    }

    // Reads: compute the offset between each original element and its clone.
    for (let i = 0; i < clones.length; i++) {
      const clone = clones[i];
      const offset = cloneOffsets[i];
      if (!offset) continue;
      const cardRect = cards[i].getBoundingClientRect();
      getLocalOffset(clone, cardRect.x, cardRect.y, offset);
    }

    // Writes: position each clone at the computed offset.
    for (let i = 0; i < clones.length; i++) {
      const offset = cloneOffsets[i];
      if (!offset) continue;
      clones[i].style.left = `${offset.x}px`;
      clones[i].style.top = `${offset.y}px`;
    }

    return clones;
  },
  container: () => document.body,
  onStart: () => {
    for (const card of cards) card.classList.add('dragging');
  },
  onEnd: (drag) => {
    const items = drag.items;
    const xTranslations: number[] = [];
    const yTranslations: number[] = [];

    // Compute all the translations first in a single pass. This way we
    // don't cause extra reflows by updating the style of one element at a
    // time.
    for (let i = 0; i < items.length; i++) {
      const card = cards[i];
      const dragItem = items[i];
      if (!card) continue;

      // Compute the offset between the original element and the drag preview.
      const offset = getLocalOffset(card, dragItem.clientRect.x, dragItem.clientRect.y);
      const parts = (getComputedStyle(card).translate || '').split(' ');
      const x = (parseFloat(parts[0]) || 0) + offset.x;
      const y = (parseFloat(parts[1]) || 0) + offset.y;
      xTranslations[i] = x;
      yTranslations[i] = y;
    }

    // Apply all DOM writes in a single pass.
    for (let i = 0; i < items.length; i++) {
      const card = cards[i];
      const dragItem = items[i];
      if (!card || !dragItem) continue;

      // Apply the computed translations.
      card.style.translate = `${xTranslations[i]}px ${yTranslations[i]}px`;

      // Remove the drag preview clone.
      dragItem.element.remove();

      // Remove the dragging class.
      card.classList.remove('dragging');
    }
  },
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Draggable - Multi-Item Drag Preview</title>
    <meta
      name="description"
      content="One draggable moves three items simultaneously via drag preview clones. Each item sits inside its own overflow-hidden container with different complex CSS transforms (scale, skew, rotation). The core's transform normalization ensures every clone matches its original's visual shape after reparenting to document.body."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="container">
      <div class="container-inner">
        <div class="card draggable" tabindex="0">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
            <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
            <path
              d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
            />
          </svg>
        </div>
      </div>
      <div class="container-label">skew(-8deg)</div>
    </div>
    <div class="container">
      <div class="container-inner">
        <div class="card draggable" tabindex="0">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
            <path
              d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
            />
          </svg>
        </div>
      </div>
      <div class="container-label">rotate(12deg)</div>
    </div>
    <div class="container">
      <div class="container-inner">
        <div class="card draggable" tabindex="0">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
            <path
              d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
            />
          </svg>
        </div>
      </div>
      <div class="container-label">skew(5deg) rotate(-6deg)</div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  height: 100%;
  display: flex;
  flex-flow: row nowrap;
  justify-content: center;
  align-items: stretch;
  gap: 10px;
  padding: 10px;
}

.container {
  position: relative;
  flex: 1;
  overflow: hidden;
  border-radius: 10px;
  border: 1.5px solid rgba(255, 255, 255, 0.15);
  background: rgba(255, 255, 255, 0.03);
}

.container-inner {
  position: absolute;
  inset: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}

.container:nth-child(1) .container-inner {
  transform: scale(0.7) skew(-8deg, -8deg);
}

.container:nth-child(2) .container-inner {
  transform: scale(0.6) rotate(12deg);
}

.container:nth-child(3) .container-inner {
  transform: scale(0.8) skew(5deg, 5deg) rotate(-6deg);
}

.card.draggable {
  position: relative;
  transform: scale(1.15);
  transform-origin: 50% 50%;
}

.container-inner > .card.draggable.dragging {
  opacity: 0.3;
}

.container-label {
  position: absolute;
  bottom: 6px;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 10px;
  font-family: monospace;
  color: rgba(255, 255, 255, 0.3);
  pointer-events: none;
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

DndObserver - Basic

A basic example of using DndObserver with Draggable and Droppable elements. Here we highlight the dropzone element that overlaps most with the dragged element.

ts
import {
  DndObserver,
  DndObserverEventType,
  Draggable,
  Droppable,
  KeyboardMotionSensor,
  PointerSensor,
} from 'dragdoll';

let zIndex = 0;

// Initialize dnd observer and get elements
const dndObserver = new DndObserver();
const draggableElements = [...document.querySelectorAll('.draggable')] as HTMLElement[];
const droppableElements = [...document.querySelectorAll('.droppable')] as HTMLElement[];

// Create droppables
droppableElements.forEach((element) => {
  const droppable = new Droppable(element);
  droppable.data.overIds = new Set<number>();
  droppable.data.droppedIds = new Set<number>();
  dndObserver.addDroppables([droppable]);
});

// Create draggables
draggableElements.forEach((element) => {
  const draggable = new Draggable([new PointerSensor(element), new KeyboardMotionSensor(element)], {
    elements: () => [element],
    startPredicate: () => !element.classList.contains('dragging'),
    onStart: () => {
      element.classList.add('dragging');
      element.style.zIndex = `${++zIndex}`;
    },
    onEnd: () => {
      element.classList.remove('dragging');
    },
  });
  dndObserver.addDraggables([draggable]);
});

// DnD logic

// On drag start loop through all target droppables and remove the draggable id
// from the dropped ids set. If the dropped ids set is empty, remove the
// "draggable-dropped" class from the droppable element.
dndObserver.on(DndObserverEventType.Start, (data) => {
  const { draggable, targets } = data;
  targets.forEach((droppable) => {
    droppable.data.droppedIds.delete(draggable.id);
    if (droppable.data.droppedIds.size === 0) {
      droppable.element?.classList.remove('draggable-dropped');
    }
  });
});

// On each collision change, keep track of the overIds set for each droppable
// and update the "draggable-over" class based on the over ids set.
dndObserver.on(DndObserverEventType.Collide, (data) => {
  const { draggable, contacts, removedContacts } = data;

  // Remove the draggable id from the droppables that stopped colliding and
  // remove the "draggable-over" class from the droppable element if there are
  // no more draggable ids in the over ids set.
  removedContacts.forEach((target) => {
    target.data.overIds.delete(draggable.id);
    if (target.data.overIds.size === 0) {
      target.element?.classList.remove('draggable-over');
    }
  });

  // Add the draggable to the first colliding droppable (best match), and remove
  // the draggable from the other colliding droppables. Update the
  // "draggable-over" class based on the over ids set.
  let i = 0;
  for (const droppable of contacts) {
    if (i === 0) {
      droppable.data.overIds.add(draggable.id);
      droppable.element?.classList.add('draggable-over');
    } else {
      droppable.data.overIds.delete(draggable.id);
      if (droppable.data.overIds.size === 0) {
        droppable.element?.classList.remove('draggable-over');
      }
    }
    ++i;
  }
});

dndObserver.on(DndObserverEventType.End, (data) => {
  const { draggable, contacts } = data;

  // For the first colliding droppable (best match), add the draggable id to the
  // dropped ids set, add the "draggable-dropped" class to the droppable
  // element, and remove the draggable id from the over ids set. If the over ids
  // set is empty, remove the "draggable-over" class from the droppable element.
  for (const droppable of contacts) {
    droppable.data.droppedIds.add(draggable.id);
    droppable.element?.classList.add('draggable-dropped');
    droppable.data.overIds.delete(draggable.id);
    if (droppable.data.overIds.size === 0) {
      droppable.element?.classList.remove('draggable-over');
    }
    return;
  }
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>DndObserver - Basic</title>
    <meta
      name="description"
      content="A basic example of using DndObserver with Draggable and Droppable elements. Here we highlight the dropzone element that overlaps most with the dragged element."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="draggables">
      <div class="card draggable" tabindex="0">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
          <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path
            d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
          />
        </svg>
      </div>
      <div class="card draggable" tabindex="0">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
          <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path
            d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
          />
        </svg>
      </div>
      <div class="card draggable" tabindex="0">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
          <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path
            d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
          />
        </svg>
      </div>
      <div class="card draggable" tabindex="0">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
          <!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path
            d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
          />
        </svg>
      </div>
    </div>
    <div class="droppables">
      <div class="droppable"></div>
      <div class="droppable"></div>
      <div class="droppable"></div>
      <div class="droppable"></div>
    </div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
body {
  flex-flow: column nowrap;
  align-items: normal;
  justify-content: safe center;
  gap: 10px;
  width: 100%;
  height: 100%;
  padding: 10px;
  display: flex;
}

.draggables,
.droppables {
  width: 100%;
  display: flex;
  flex-flow: row nowrap;
  justify-content: safe center;
  align-items: safe center;
  align-content: safe center;
  gap: 10px;
}

.card.draggable {
  position: relative;
  flex-grow: 0;
  flex-shrink: 0;
}

.droppable {
  width: 100px;
  height: 100px;
  background-color: var(--bg-color);
  border-radius: 7px;
  border: 1.5px solid var(--theme-color);
  transition:
    border-color 0.2s ease-out,
    box-shadow 0.2s ease-out;
  box-shadow:
    0 0 0 2px transparent,
    0 0 0 3.5px transparent;

  &.draggable-dropped {
    border-color: var(--card-bgColor--drag);
    box-shadow:
      0 0 0 2px transparent,
      0 0 0 3.5px transparent;
  }

  &.draggable-over {
    border-color: var(--card-bgColor--focus);
    box-shadow:
      0 0 0 2px var(--bg-color),
      0 0 0 3.5px var(--card-bgColor--focus);
  }
}

@media (width < 430px) {
  .card.draggable,
  .droppable {
    width: calc((100% - 50px) / 4);
    aspect-ratio: 1 / 1;
    height: auto;
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

DndObserver - Advanced Collision Detector

Advanced collision detection with scrollable droppable lists. Here we can see how the advanced collision detector respects the visibility of the droppables. Only the visible parts of the droppables (as seen from the perspective of the draggable) are considered for collisions.

ts
import {
  AdvancedCollisionData,
  AdvancedCollisionDetector,
  AnyDraggable,
  autoScrollPlugin,
  DndObserver,
  DndObserverEventType,
  Draggable,
  Droppable,
  getLocalOffset,
  KeyboardMotionSensor,
  PointerSensor,
} from 'dragdoll';

// Keep track of the best match droppable.
const bestMatchMap: Map<AnyDraggable, Droppable> = new Map();

// Get elements.
const scrollContainers = [...document.querySelectorAll('.scroll-list')] as HTMLElement[];
const draggableElements = [...document.querySelectorAll('.draggable')] as HTMLElement[];
const droppableElements = [...document.querySelectorAll('.droppable')] as HTMLElement[];

// Initialize DndObserver.
const dndObserver = new DndObserver<AdvancedCollisionData>({
  collisionDetector: (ctx) => new AdvancedCollisionDetector(ctx),
});

// Create droppables.
const droppables: Droppable[] = [];
for (const droppableElement of droppableElements) {
  const droppable = new Droppable(droppableElement);
  droppables.push(droppable);
}

// Create draggables.
const draggables: AnyDraggable[] = [];
for (const draggableElement of draggableElements) {
  const draggable = new Draggable(
    [new PointerSensor(draggableElement), new KeyboardMotionSensor(draggableElement)],
    {
      // Only move the draggable element.
      elements: () => [draggableElement],
      // Use the drag container (has contain: layout).
      container: document.getElementById('drag-container') as HTMLElement,
      // Freeze the width and height of the dragged element since we are using
      // a custom container and the element has percentage based values for
      // some of its properties.
      frozenStyles: () => ['width', 'height'],
      // Allow the drag to start only if the element is not animating.
      startPredicate: () => !draggableElement.classList.contains('animate'),
      // Toggle the dragging class on the draggable element when the drag starts
      // and ends.
      onStart: () => {
        draggableElement.classList.add('dragging');
      },
      onEnd: () => {
        draggableElement.classList.remove('dragging');
      },
    },
  ).use(
    // Allow the draggable to scroll the scroll containers when the dragged
    // element is close to its edges.
    autoScrollPlugin({
      targets: scrollContainers.map((scrollContainer) => ({
        element: scrollContainer,
        axis: 'y',
        padding: { top: 0, bottom: 0 },
      })),
    }),
  );
  draggables.push(draggable);
}

// Add droppables and draggables to the dnd observer.
dndObserver.addDroppables(droppables);
dndObserver.addDraggables(draggables);

// On draggable collision with droppables.
dndObserver.on(DndObserverEventType.Collide, ({ draggable, contacts }) => {
  // Get the draggable element.
  const draggableElement = draggable.drag?.items[0].element as HTMLElement | null;
  if (!draggableElement) return;

  // Get the draggable id.
  const draggableId = draggableElement.getAttribute('data-id') || '';
  if (draggableId === '') return;

  // Get the next best match droppable.
  let nextBestMatch: Droppable | null = null;
  for (const droppable of contacts) {
    // Skip if the droppable contains a different draggable.
    const containedDraggableId = droppable.element?.getAttribute('data-draggable-contained') || '';
    if (containedDraggableId && containedDraggableId !== draggableId) {
      continue;
    }

    // Skip if a different draggable is over the droppable.
    const overDraggableId = droppable.element?.getAttribute('data-draggable-over') || '';
    if (overDraggableId && overDraggableId !== draggableId) {
      continue;
    }

    // We found the next best match.
    nextBestMatch = droppable;
    break;
  }

  // Update the best match droppable if it's changed.
  const bestMatch = bestMatchMap.get(draggable);
  if (nextBestMatch !== null && nextBestMatch !== bestMatch) {
    bestMatch?.element?.removeAttribute('data-draggable-over');
    nextBestMatch?.element?.setAttribute('data-draggable-over', draggableId);
    bestMatchMap.set(draggable, nextBestMatch);
  }
});

// On drag end.
dndObserver.on(DndObserverEventType.End, ({ draggable, canceled }) => {
  const draggableElement = draggable.drag?.items[0].element as HTMLElement | null;
  if (!draggableElement) return;

  // Find out the original container and the target container based on the best
  // match droppable.
  const bestMatch = bestMatchMap.get(draggable);
  const originalContainer = draggableElement.parentElement!;
  const targetContainer =
    !canceled && bestMatch ? (bestMatch.element as HTMLElement) : originalContainer;

  // Record the element's current viewport position before any DOM changes.
  const rect = draggableElement.getBoundingClientRect();

  // Clear the drag-applied transform so the element returns to its natural
  // CSS position within whichever container it ends up in.
  draggableElement.style.transform = '';

  // If draggable moved into a different container.
  if (originalContainer !== targetContainer) {
    targetContainer.appendChild(draggableElement);

    // Move the data-draggable-contained attribute to the target container.
    originalContainer.removeAttribute('data-draggable-contained');
    targetContainer.setAttribute(
      'data-draggable-contained',
      draggableElement.getAttribute('data-id')!,
    );
  }

  // Compute the CSS offset delta that maintains the element's viewport
  // position. getLocalOffset accounts for any ancestor transforms (scale,
  // rotation, skew) on the container.
  const delta = getLocalOffset(draggableElement, rect.x, rect.y);

  // Skip animation if the element is already at its target position.
  if (Math.abs(delta.x) < 0.5 && Math.abs(delta.y) < 0.5) {
    bestMatch?.element?.removeAttribute('data-draggable-over');
    bestMatchMap.delete(draggable);
    return;
  }

  // Apply transform to hold the element at its drag-end viewport position.
  draggableElement.style.transform = `translate(${delta.x}px, ${delta.y}px)`;

  // Force a reflow to commit the starting transform BEFORE adding the
  // transition. Without this the browser's last committed state for transform
  // is "none" (from the getLocalOffset reflow), and the transition has no
  // starting point to animate from.
  draggableElement.clientHeight;

  // Now enable the transition and animate to identity.
  draggableElement.classList.add('animate');
  const onTransitionEnd = (e: TransitionEvent) => {
    if (e.target === draggableElement && e.propertyName === 'transform') {
      draggableElement.classList.remove('animate');
      draggableElement.style.transform = '';
      document.body.removeEventListener('transitionend', onTransitionEnd);
    }
  };
  document.body.addEventListener('transitionend', onTransitionEnd);
  draggableElement.style.transform = 'matrix(1, 0, 0, 1, 0, 0)';

  // Reset the best match droppable.
  bestMatch?.element?.removeAttribute('data-draggable-over');
  bestMatchMap.delete(draggable);
});
html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>DndObserver - Advanced Collision Detector</title>
    <meta
      name="description"
      content="Advanced collision detection with scrollable droppable lists. Here we can see how the advanced collision detector respects the visibility of the droppables. Only the visible parts of the droppables (as seen from the perspective of the draggable) are considered for collisions."
    />
    <meta
      name="viewport"
      content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
    />
    <link rel="stylesheet" href="base.css" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div class="container">
      <div class="scroll-list">
        <div class="droppable" data-draggable-contained="1">
          <div class="card draggable" tabindex="0" data-id="1">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
        </div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
      </div>
      <div class="scroll-list">
        <div class="droppable" data-draggable-contained="2">
          <div class="card draggable" tabindex="0" data-id="2">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
              <path
                d="M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l9.4-9.4L224 224l-114.7 0 9.4-9.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-9.4-9.4L224 288l0 114.7-9.4-9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-9.4 9.4L288 288l114.7 0-9.4 9.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L288 224l0-114.7 9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-64-64z"
              />
            </svg>
          </div>
        </div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
        <div class="droppable"></div>
      </div>
    </div>
    <div id="drag-container"></div>

    <script type="module" src="index.ts"></script>
  </body>
</html>
css
#drag-container {
  position: fixed;
  inset: 0;
  pointer-events: none;
  contain: layout;
  z-index: 9999;
}

body {
  display: flex;
  align-items: safe center;
  justify-content: safe center;
  width: 100%;
  height: 100%;
}

.container {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: safe center;
  justify-content: safe center;
  flex-direction: row;
  gap: 20px;
  padding: 20px;
}

.scroll-list {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  justify-content: safe center;
  gap: 20px;
  padding: 20px;
  display: grid;
  position: relative;
  background: rgba(255, 255, 255, 0.1);
  border-radius: 7px;
  width: 260px;
  height: min(520px, 100%);
  overflow: hidden scroll;
}

.droppable {
  position: relative;
  width: 100%;
  min-width: 0;
  aspect-ratio: 1 / 1;
  background-color: var(--bg-color);
  border-radius: 17px;
  border: 1.5px solid var(--theme-color);
  transition:
    border-color 0.2s ease-out,
    box-shadow 0.2s ease-out;
  box-shadow:
    0 0 0 2px transparent,
    0 0 0 3.5px transparent;

  &[data-draggable-over] {
    border-color: var(--card-bgColor--focus);
    box-shadow:
      0 0 0 2px var(--bg-color),
      0 0 0 3.5px var(--card-bgColor--focus);
  }
}

.card.draggable {
  position: absolute;
  top: 10px;
  left: 10px;
  width: calc(100% - 20px);
  height: calc(100% - 20px);
  z-index: 100;

  &.animate {
    transition: transform 0.3s cubic-bezier(0.33, 0.975, 0, 1.65);
  }
}

@media (width < 600px) {
  .scroll-list {
    grid-template-columns: repeat(1, minmax(0, 1fr));
    justify-content: safe center;
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

Sortable List - Accessible

A sortable list with two interaction modes. (1) Pointer drag: drag items via mouse or touch — a fixed-position clone follows the pointer while the original stays in-flow as a translucent placeholder; DndObserver detects collisions to trigger reorder. (2) Keyboard reorder: focus an item and press Shift+Space or Shift+Enter to pick up, arrow keys to move, Space/Enter to drop, Escape to cancel. During either interaction, items are repositioned visually with CSS transforms while the DOM order stays fixed. The final DOM reorder only happens on drop. A live region announces every position change for screen readers.

ts
// Sortable list with pointer drag and keyboard reorder.
//
// Pointer: drag items via mouse/touch. A clone follows the pointer while the
// original stays in-flow as a placeholder. DndObserver handles collision-based
// reorder.
//
// Keyboard: Shift+Space/Enter to pick up, arrows to move, Space/Enter to
// drop, Escape to cancel.
//
// Items are visually repositioned with CSS transforms during drag. DOM order
// only changes on drop.

import {
  AdvancedCollisionData,
  AdvancedCollisionDetector,
  autoScrollPlugin,
  DndObserver,
  DndObserverEventType,
  Draggable,
  DraggableModifier,
  Droppable,
  PointerSensor,
  startOffsetModifier,
} from 'dragdoll';

// ---------
// Constants
// ---------

const ITEM_COUNT = 100;
const POINTER_START_THRESHOLD_SQ = 8 * 8;
const SWAP_ANIM_DURATION = 150;
const DROP_ANIM_DURATION = 150;
const CANCEL_ANIM_DURATION = 200;
const SWAP_OVERLAP_THRESHOLD = 51;

// -----
// Types
// -----

interface ItemData {
  label: string;
  element: HTMLLIElement;
  link: HTMLAnchorElement;
  droppable: Droppable;
  domIndex: number;
}

// --------------
// DOM references
// --------------

const listEl = document.getElementById('sortable-list') as HTMLUListElement;
const liveRegion = document.getElementById('dnd-live-region') as HTMLDivElement;
const dragContainer = document.getElementById('drag-container') as HTMLDivElement;

// -----
// State
// -----

const items: ItemData[] = [];
const itemsByElement = new Map<HTMLLIElement, ItemData>();

let itemHeight = 0;
let itemStride = 0;
let listOffsetTop = 0;

let virtualOrder: number[] | null = null;
let virtualIndexOf: number[] | null = null;

let pointerDrag: {
  item: ItemData;
  preview: HTMLLIElement;
  originalIndex: number;
} | null = null;

let lastSwapFromIdx = -1;

let a11yDrag: {
  item: ItemData;
  originalIndex: number;
  currentIndex: number;
} | null = null;

let cachedListRect: DOMRect | null = null;

// -----------
// Measurement
// -----------

function measureItemDimensions() {
  const firstRect = items[0].element.getBoundingClientRect();
  const secondRect = items[1].element.getBoundingClientRect();
  itemHeight = firstRect.height;
  itemStride = secondRect.top - firstRect.top;
  listOffsetTop = firstRect.top - listEl.getBoundingClientRect().top;
}

function invalidateListRectCache() {
  cachedListRect = null;
}

function getListRect(): DOMRect {
  return (cachedListRect ??= listEl.getBoundingClientRect());
}

// --------------
// Virtual layout
// --------------

function initVirtualOrder() {
  measureItemDimensions();
  virtualOrder = items.map((_, i) => i);
  virtualIndexOf = items.map((_, i) => i);
}

function getVirtualIndex(domIndex: number): number {
  return virtualIndexOf ? virtualIndexOf[domIndex] : domIndex;
}

function virtualSwap(fromIdx: number, toIdx: number, animate = true) {
  if (!virtualOrder || !virtualIndexOf || fromIdx === toIdx) return;

  const lo = Math.min(fromIdx, toIdx);
  const hi = Math.max(fromIdx, toIdx);

  const [moved] = virtualOrder.splice(fromIdx, 1);
  virtualOrder.splice(toIdx, 0, moved);

  for (let vi = lo; vi <= hi; vi++) {
    virtualIndexOf[virtualOrder[vi]] = vi;
  }

  for (let vi = lo; vi <= hi; vi++) {
    const domIdx = virtualOrder[vi];
    const el = items[domIdx].element;
    const newY = (vi - domIdx) * itemStride;
    const prevY = parseFloat(el.style.transform?.match(/translateY\((.+?)px\)/)?.[1] || '0');
    if (prevY === newY) continue;

    el.style.transform = newY === 0 ? '' : `translateY(${newY}px)`;

    if (animate) {
      const anims = el.getAnimations();
      for (let j = 0; j < anims.length; j++) anims[j].cancel();
      el.animate(
        [
          { transform: `translateY(${prevY}px)` },
          { transform: newY === 0 ? 'translateY(0px)' : `translateY(${newY}px)` },
        ],
        { duration: SWAP_ANIM_DURATION, easing: 'ease' },
      );
    }
  }
}

function commitOrder() {
  if (!virtualOrder) return;

  const newItems = virtualOrder.map((domIdx) => items[domIdx]);
  for (let i = 0; i < newItems.length; i++) {
    newItems[i].domIndex = i;
    listEl.appendChild(newItems[i].element);
  }

  items.length = 0;
  items.push(...newItems);
  clearAllTransforms();
  virtualOrder = null;
  virtualIndexOf = null;
}

function clearAllTransforms() {
  for (const item of items) {
    const anims = item.element.getAnimations();
    for (let i = 0; i < anims.length; i++) anims[i].cancel();
    item.element.style.transform = '';
  }
}

function animateTransformsToZero(duration: number) {
  for (const item of items) {
    const el = item.element;
    const t = el.style.transform;
    if (!t || t === 'translateY(0px)') continue;

    const anims = el.getAnimations();
    for (let i = 0; i < anims.length; i++) anims[i].cancel();

    el.style.transform = '';
    el.animate([{ transform: t }, { transform: 'translateY(0px)' }], { duration, easing: 'ease' });
  }
}

// ---------------
// Preview helpers
// ---------------

function createPreviewClone(element: HTMLLIElement): HTMLLIElement {
  const preview = element.cloneNode(true) as HTMLLIElement;
  const rect = element.getBoundingClientRect();
  const parentRect = element.parentElement!.getBoundingClientRect();

  const s = preview.style;
  s.position = 'absolute';
  s.left = `${rect.left - parentRect.left}px`;
  s.top = `${rect.top - parentRect.top}px`;
  s.width = `${rect.width}px`;
  s.margin = '0';
  s.boxSizing = 'border-box';
  s.contain = 'layout';

  preview.classList.add('drag-preview');
  preview.setAttribute('aria-hidden', 'true');
  element.parentElement!.appendChild(preview);
  return preview;
}

function animatePreviewToTarget(
  preview: HTMLLIElement,
  target: HTMLLIElement,
  duration: number,
  onDone: () => void,
) {
  const anims = target.getAnimations();
  for (let i = 0; i < anims.length; i++) anims[i].finish();

  const pRect = preview.getBoundingClientRect();
  const tRect = target.getBoundingClientRect();
  const dx = tRect.left - pRect.left;
  const dy = tRect.top - pRect.top;

  if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
    onDone();
    return;
  }

  const anim = preview.animate([{ translate: '0px 0px' }, { translate: `${dx}px ${dy}px` }], {
    duration,
    easing: 'ease',
    fill: 'forwards',
    composite: 'add',
  });
  anim.onfinish = onDone;
}

// ---------
// Utilities
// ---------

function isPointerDistanceAboveThreshold(
  x: number,
  y: number,
  startX: number,
  startY: number,
  thresholdSq: number,
): boolean {
  const dx = x - startX;
  const dy = y - startY;
  return dx * dx + dy * dy >= thresholdSq;
}

function announce(message: string) {
  liveRegion.textContent = message;
}

// -----------
// DndObserver
// -----------

const dndObserver = new DndObserver<AdvancedCollisionData>({
  collisionDetector: (ctx) => new AdvancedCollisionDetector(ctx),
});

// ------------
// Pointer drag
// ------------

function onScrollDuringDrag() {
  invalidateListRectCache();
  dndObserver.updateDroppableClientRects();
}

function pointerDragEnd(cancelled: boolean) {
  window.removeEventListener('scroll', onScrollDuringDrag);

  const drag = pointerDrag!;
  const li = drag.item.element;
  const preview = drag.preview;
  pointerDrag = null;

  const cleanup = () => {
    preview.remove();
    li.classList.remove('placeholder');
    listEl.classList.remove('is-dragging');
  };

  if (cancelled) {
    animateTransformsToZero(CANCEL_ANIM_DURATION);
    virtualOrder = null;
    virtualIndexOf = null;
    animatePreviewToTarget(preview, li, CANCEL_ANIM_DURATION, cleanup);
  } else {
    commitOrder();
    animatePreviewToTarget(preview, li, DROP_ANIM_DURATION, cleanup);
  }
}

// ----------------
// Keyboard reorder
// ----------------

function a11yStart(item: ItemData) {
  initVirtualOrder();
  a11yDrag = { item, originalIndex: item.domIndex, currentIndex: item.domIndex };
  item.element.classList.add('a11y-dragging');
  item.element.scrollIntoView({ block: 'nearest' });

  announce(
    `Picked up ${item.label}. Position ${item.domIndex + 1} of ${items.length}. ` +
      `Use arrow keys to move, Space or Enter to drop, Escape to cancel.`,
  );
}

function a11yMove(direction: -1 | 1) {
  if (!a11yDrag) return;

  const drag = a11yDrag;
  const newIndex = drag.currentIndex + direction;
  if (newIndex < 0 || newIndex >= items.length) return;

  virtualSwap(drag.currentIndex, newIndex, false);
  drag.currentIndex = newIndex;

  // Scroll using computed position — no DOM read per item needed.
  const freshListRect = listEl.getBoundingClientRect();
  const gap = itemStride - itemHeight;
  const targetTop = freshListRect.top + listOffsetTop + newIndex * itemStride;
  const targetBottom = targetTop + itemHeight;
  if (targetTop - gap < 0) {
    window.scrollBy(0, targetTop - gap);
  } else if (targetBottom + gap > window.innerHeight) {
    window.scrollBy(0, targetBottom + gap - window.innerHeight);
  }

  announce(`${drag.item.label}, position ${newIndex + 1} of ${items.length}.`);
}

function a11yEnd(cancel: boolean) {
  if (!a11yDrag) return;

  const drag = a11yDrag;
  a11yDrag = null;
  drag.item.element.classList.remove('a11y-dragging');

  if (cancel) {
    animateTransformsToZero(CANCEL_ANIM_DURATION);
    virtualOrder = null;
    virtualIndexOf = null;
  } else {
    commitOrder();
  }

  announce(
    cancel
      ? `${drag.item.label} reorder cancelled. Returned to position ${drag.originalIndex + 1}.`
      : `${drag.item.label} dropped at position ${drag.currentIndex + 1} of ${items.length}.`,
  );

  drag.item.link.focus({ preventScroll: true });
}

// ----------------
// Keyboard handler
// ----------------

document.addEventListener('keydown', (e) => {
  if (a11yDrag) {
    switch (e.key) {
      case 'ArrowUp':
        e.preventDefault();
        return a11yMove(-1);
      case 'ArrowDown':
        e.preventDefault();
        return a11yMove(1);
      case ' ':
      case 'Enter':
        e.preventDefault();
        return a11yEnd(false);
      case 'Escape':
        e.preventDefault();
        return a11yEnd(true);
    }
    return;
  }

  if (e.shiftKey && (e.key === ' ' || e.key === 'Enter')) {
    const li = (e.target as Element).closest('.sortable-item') as HTMLLIElement | null;
    const item = li && itemsByElement.get(li);
    if (item) {
      e.preventDefault();
      a11yStart(item);
    }
  }
});

// ----------
// Build list
// ----------

const droppables: Droppable[] = [];
const draggables: Draggable<PointerSensor>[] = [];

for (let i = 0; i < ITEM_COUNT; i++) {
  const label = `Item ${i + 1}`;

  const li = document.createElement('li');
  li.className = 'sortable-item';

  const link = document.createElement('a');
  link.href = 'https://muuri.dev';
  link.target = '_blank';
  link.rel = 'noopener noreferrer';
  link.draggable = false;
  link.textContent = label;
  link.setAttribute('aria-roledescription', 'sortable item');
  link.setAttribute('aria-describedby', 'dnd-instructions');
  li.appendChild(link);
  listEl.appendChild(li);

  const pointerSensor = new PointerSensor(link);
  const itemData: ItemData = { label, element: li, link, domIndex: i } as ItemData;
  items.push(itemData);
  itemsByElement.set(li, itemData);

  const droppable = new Droppable(li, {
    data: { item: itemData },
    computeClientRect: () => {
      const idx = getVirtualIndex(itemData.domIndex);
      const listRect = getListRect();
      return {
        x: listRect.left,
        y: listRect.top + listOffsetTop + idx * itemStride,
        width: listRect.width,
        height: itemHeight,
      };
    },
  });
  itemData.droppable = droppable;

  const draggable = new Draggable<PointerSensor>([pointerSensor], {
    elements: () => {
      initVirtualOrder();
      invalidateListRectCache();
      const preview = createPreviewClone(li);
      pointerDrag = { item: itemData, preview, originalIndex: itemData.domIndex };
      return [preview];
    },
    container: () => dragContainer,
    startPredicate: ({ event }) => {
      if (a11yDrag) return false;
      return isPointerDistanceAboveThreshold(
        event.x,
        event.y,
        event.startX,
        event.startY,
        POINTER_START_THRESHOLD_SQ,
      )
        ? true
        : undefined;
    },
    positionModifiers: [
      startOffsetModifier as unknown as DraggableModifier<PointerSensor>,
      (change) => {
        change.x = 0;
        return change;
      },
    ],
    frozenStyles: () => ['width', 'height'],
    onStart: () => {
      li.classList.add('placeholder');
      lastSwapFromIdx = -1;
      listEl.classList.add('is-dragging');
      window.addEventListener('scroll', onScrollDuringDrag);
    },
    onMove: () => {
      lastSwapFromIdx = -1;
    },
    onEnd: ({ endEvent }) => {
      pointerDragEnd(endEvent?.type === 'cancel');
    },
  }).use(
    autoScrollPlugin({
      targets: [{ element: window, axis: 'y', padding: { top: Infinity, bottom: Infinity } }],
    }),
  );

  droppables.push(droppable);
  draggables.push(draggable);
}

dndObserver.addDroppables(droppables);
dndObserver.addDraggables(draggables);

// -----------------------
// Collision-based reorder
// -----------------------

dndObserver.on(DndObserverEventType.Collide, ({ collisions }) => {
  if (!pointerDrag || !virtualOrder) return;

  const draggedItem = pointerDrag.item;
  const draggedDomIdx = draggedItem.domIndex;

  for (const collision of collisions) {
    if (collision.intersectionScore < SWAP_OVERLAP_THRESHOLD) break;

    const targetDroppable = dndObserver.droppables.get(collision.droppableId);
    if (!targetDroppable) continue;

    const targetItem = targetDroppable.data.item as ItemData;
    if (targetItem === draggedItem) continue;

    const currentVIdx = getVirtualIndex(draggedDomIdx);
    const targetVIdx = getVirtualIndex(targetItem.domIndex);
    if (currentVIdx === targetVIdx || targetVIdx === lastSwapFromIdx) continue;

    lastSwapFromIdx = currentVIdx;
    virtualSwap(currentVIdx, targetVIdx);
    invalidateListRectCache();
    dndObserver.updateDroppableClientRects();
    break;
  }
});

// -------------------
// Initial measurement
// -------------------

measureItemDimensions();
html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Sortable List - Accessible</title>
    <meta
      name="description"
      content="A sortable list with two interaction modes. (1) Pointer drag: drag items via mouse or touch — a fixed-position clone follows the pointer while the original stays in-flow as a translucent placeholder; DndObserver detects collisions to trigger reorder. (2) Keyboard reorder: focus an item and press Shift+Space or Shift+Enter to pick up, arrow keys to move, Space/Enter to drop, Escape to cancel. During either interaction, items are repositioned visually with CSS transforms while the DOM order stays fixed. The final DOM reorder only happens on drop. A live region announces every position change for screen readers."
    />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="stylesheet" href="index.css" />
  </head>
  <body>
    <div id="dnd-instructions" class="sr-only">
      Press Shift plus Space or Shift plus Enter to reorder. Use arrow keys to move. Press Space or
      Enter to drop, or Escape to cancel.
    </div>
    <div id="dnd-live-region" class="sr-only" aria-live="assertive" aria-atomic="true"></div>
    <ul id="sortable-list" role="list" aria-label="Sortable items"></ul>
    <div id="drag-container"></div>
    <script type="module" src="index.ts"></script>
  </body>
</html>
css
:root {
  --color: rgba(255, 255, 245, 0.86);
  --bg-color: #111;

  --list-gap: 6px;

  --item-border-radius: 6px;
  --item-height: 40px;
  --item-padding-inline: 12px;

  --item-color: rgba(255, 255, 245, 0.86);
  --item-color-hover: rgba(255, 255, 245, 0.9);
  --item-color-focus: #fff;
  --item-color-dragging: rgba(255, 255, 245, 1);
  --item-bg-color: #222;
  --item-bg-color-hover: #2a2a2a;
  --item-bg-color-focus: #333;
  --item-bg-color-dragging: #1a3a1a;
  --item-border-color: #333;
  --item-border-color-hover: #444;
  --item-border-color-focus: #555;
  --item-border-color-dragging: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  font-family:
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Roboto,
    sans-serif;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(100%);
  border: 0;
  white-space: nowrap;
}

#drag-container {
  position: fixed;
  inset: 0;
  pointer-events: none;
  contain: layout;
  z-index: 9999;
}

#sortable-list {
  position: relative;
  list-style: none;
  margin: 0;
  padding: 20px 0;
  width: 100%;
  max-width: 400px;
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  gap: var(--list-gap);
}

.sortable-item {
  list-style: none;
  overflow-anchor: none;
  margin: 0;
  padding: 0;
  border-radius: var(--item-border-radius);

  &.placeholder {
    opacity: 0.4;
  }

  &.a11y-dragging {
    position: relative;
    z-index: 1;
  }

  &.drag-preview {
    pointer-events: none;
    z-index: 1000;
  }

  & a {
    display: flex;
    align-items: center;
    height: var(--item-height);
    padding: 0 var(--item-padding-inline);
    color: var(--item-color);
    background: var(--item-bg-color);
    border: 1px solid var(--item-border-color);
    touch-action: none;
    border-radius: inherit;
    text-decoration: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    cursor: pointer;
    outline: none;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
    -webkit-touch-callout: none;

    box-shadow: 0 4px 16px rgba(0, 0, 0, 0);

    transition:
      color 150ms ease,
      background-color 150ms ease,
      border-color 150ms ease,
      box-shadow 150ms ease;

    :not(.is-dragging) > .sortable-item > &:hover {
      color: var(--item-color-hover);
      background: var(--item-bg-color-hover);
      border-color: var(--item-border-color-hover);
    }

    &:focus {
      outline: none;
    }

    &:focus-visible {
      color: var(--item-color-focus);
      background: var(--item-bg-color-focus);
      border-color: var(--item-border-color-focus);
      box-shadow: 0 0 0 2px var(--item-border-color-focus);
    }

    .sortable-item.drag-preview & {
      color: var(--item-color-dragging);
      background: var(--item-bg-color-dragging);
      border-color: var(--item-border-color-dragging);
      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
      cursor: grabbing;
    }

    .sortable-item.a11y-dragging & {
      color: var(--item-color-dragging);
      background: var(--item-bg-color-dragging);
      border-color: var(--item-border-color-dragging);
    }
  }
}
css
:root {
  --bg-color: #111;
  --color: rgba(255, 255, 245, 0.86);
  --theme-color: #ff5555;
  --card-color: rgba(0, 0, 0, 0.7);
  --card-bgColor: var(--theme-color);
  --card-color--focus: var(--card-color);
  --card-bgColor--focus: #db55ff;
  --card-color--drag: var(--card-color);
  --card-bgColor--drag: #55ff9c;
}

* {
  box-sizing: border-box;
}

html {
  height: 100%;
  background: var(--bg-color);
  color: var(--color);
  background-size: 40px 40px;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.1) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}

body {
  margin: 0;
  overflow: hidden;
}

.card {
  display: flex;
  justify-content: safe center;
  align-items: safe center;
  width: 100px;
  height: 100px;
  background-color: var(--card-bgColor);
  color: var(--card-color);
  border-radius: 7px;
  border: 1.5px solid var(--bg-color);
  font-size: 30px;

  & svg {
    width: 1em;
    height: 1em;
    fill: var(--card-color);
  }

  @media (hover: hover) and (pointer: fine) {
    &:hover,
    &:focus-visible {
      background-color: var(--card-bgColor--focus);
      color: var(--card-color--focus);

      & svg {
        fill: var(--card-color--focus);
      }
    }

    &:focus-visible {
      outline-offset: 4px;
      outline: 1px solid var(--card-bgColor--focus);
    }
  }

  &.draggable {
    cursor: grab;
    touch-action: none;
  }

  &.dragging {
    cursor: grabbing;
    background-color: var(--card-bgColor--drag);
    color: var(--card-color--drag);

    & svg {
      fill: var(--card-color--drag);
    }

    @media (hover: hover) and (pointer: fine) {
      &:focus-visible {
        outline: 1px solid var(--card-bgColor--drag);
      }
    }
  }
}

DragDoll is released under the MIT License.