TODO: General session, mode specific session

Drag Start

When a drag starts, several things can happen depending on the current mode.

Update Item

If session.dragVisual === "item", the dragged item must begin moving alongside the pointer. SnapSort will directly set the DOM element position to absolute, and begin applying CSS transforms to move the item to where the pointer is.

If session.dragVisual === "preview", the dragged item remains stationary in the DOM, while the visual representation of the drag is handled through ghost items.

Ghost Creation

After an accepted drag activates, it may create a ghost item by calling the onGhostInsert callback. The frontend framework must handle this callback to add the ghost to the DOM with proper styling.

The callback passes a GhostInsertEvent with the following fields:

type GhostInsertEvent = {
  readonly operation: "insert";
  readonly ghost: GhostState;
  readonly to: GhostState["location"];
  readonly beforeElement: HTMLElement | null;
};

Of particular interest here is the ghost.type field, which can have the following values that indicate how the ghost should be placed.

  • target-spacer: The ghost is placed where the item is about to be dropped, in other words where the drop target is located. Used by euclidean and progressive modes.
  • insertion-marker: The ghost is a marker indicating the gap between items where the dragged item will be dropped. Used by insertion mode.
  • pointer-preview: The ghost follows the pointer. Used by swap mode.
  • source-spacer the ghost stays in the original position of the item being dragged. Currently not used by any mode.

Application code must render the appropriate ghost item based on this field. For convenience, SnapSort also provides a Ghost components that can dynamically render each type. See Ghost.

Item Drag

As the pointer moves during the drag gesture, behind the scenes SnapSort is busy at work calculating the drop target based on the current position of the pointer and the item. Every mode uses a different algorithm, but they all eventually fire a predefined set of callbacks.

onGhostMove

If the mode involves moving ghosts, SnapSort fires the onGhostMove callback with the updated ghost state. This requests the application to move ghosts between locations or update their visual representation.

The application does not, however, need to handle animations; SnapSort handles that for you. This is not only for convenience (you’re welcome) but also a necessity, as SnapSort, and the engine it’s built on, SnapEngine, runs a carefully coordinated animation system to ensure smooth DOM updates. Under the hood, SnapSort takes a snapshot of the ghost before calling the callback. After the callback, another snapshot is taken, and an animation is played to interpolate between the two snapshots. Meaning, in fact, you should avoid triggering custom animations as it can interfere with the animation system.

Ghost Lifecycle Callbacks

All in all, there are three flavors of callbacks for ghosts:

  • onGhostInsert reports absent → present with ghost, to, and beforeElement.
  • onGhostMove reports present → changed with ghost, from, to, and beforeElement.
  • onGhostRemove reports present → absent with ghost and from.

The callbacks remain separate because SnapSort owns all three transitions. It can understandably be a bit annoying to handle all yourself, so SnapSort ships with the reduceRenderTree helper which you can pass the event argument of all three callbacks directly. Combined with the aforementioned Ghost component, this should make it quick and easy to set up ghosts.

<script lang="ts">
  import {
    reduceRenderTree,
    type ContainerCallbacks,
    type GhostLifecycleEvent,
  } from "@snap-engine/snapsort";
  import { Ghost, Item } from "@snap-engine/snapsort/svelte";

  function applyGhostEvent(event: GhostLifecycleEvent) {
    tree = reduceRenderTree(tree, event);
  }

  const callbacks = {
    onGhostInsert: applyGhostEvent,
    onGhostMove: applyGhostEvent,
    onGhostRemove: applyGhostEvent,
  } satisfies ContainerCallbacks;
</script>

{#each tree.entries as entry (entry.itemId)}
  {#if entry.isGhost}
    <Ghost ghost={entry.ghost} />
  {:else}
    <Item itemId={entry.itemId}>{entry.value.label}</Item>
  {/if}
{/each}
import { useCallback, useMemo } from "react";
import { Ghost, Item } from "@snap-engine/snapsort/react";
import {
  reduceRenderTree,
  type ContainerCallbacks,
  type GhostLifecycleEvent,
} from "@snap-engine/snapsort";

const applyGhostEvent = useCallback((event: GhostLifecycleEvent) => {
  setTree((current) => reduceRenderTree(current, event));
}, []);

const callbacks = useMemo(
  () =>
    ({
      onGhostInsert: applyGhostEvent,
      onGhostMove: applyGhostEvent,
      onGhostRemove: applyGhostEvent,
    }) satisfies ContainerCallbacks,
  [applyGhostEvent],
);

const children = tree.entries.map((entry) =>
  entry.isGhost ? (
    <Ghost key={entry.itemId} ghost={entry.ghost} />
  ) : (
    <Item key={entry.itemId} itemId={entry.itemId}>
      {entry.value.label}
    </Item>
  ),
);

onDropTargetChange

When the algorithm detects that a new drop target has been selected, SnapSort fires the onDropTargetChange callback with the new target location and relevant data.

This is usually used by swap mode, which can update the styling of the hovered item to make it clear it has been selected as a drop target.

Resolution Order

Each built-in SnapSort mode algorithm resolves a prospective drop target in this order:

  1. Generate the active mode’s candidate positions and group them by their directly owning container.
  2. Start with each container’s configured dropPriority, then invoke getDropPriority once. A returned number replaces the configured value; undefined preserves it.
  3. Remove containers whose effective priority is -1, then keep only candidates tied at the highest remaining priority.
  4. Let the active mode rank that candidate set and choose the exact container and index.

Priority and Rejection

By default, all items and containers have a dropPriority of 0. The getDropPriority callback can replace it every time the item moves. Return -1 to reject the destination, a nonnegative number to rank it, or undefined to preserve its configured value.

import {
  DROP_REJECT_PRIORITY,
  type DropPriorityEvent,
} from "@snap-engine/snapsort";

function prioritizeUrgentItems(event: DropPriorityEvent) {
  if (event.containerMetadata.accepts !== event.itemMetadata.kind) {
    return DROP_REJECT_PRIORITY;
  }
  return event.itemsMetadata.every((metadata) => metadata.urgent === true)
    ? event.staticPriority + 1
    : undefined;
}

This is useful if you want to make exceptions to the mode’s algorithm and prioritize specific containers, or use as tie breakers when the algorithm assigns multiple possible drop locations with the same preference.

<Container
  itemId="words-root"
  metadata={{ boardId: "sentence" }}
  config={{ dropPriority: 2, callbacks }}
>
  {#each words.entries as entry (entry.itemId)}
    {#if entry.isGhost}
      <Ghost ghost={entry.ghost} />
    {:else if entry.childTree}
      <WordContainer {entry} />
    {:else}
      <Item itemId={entry.itemId}>{entry.value.label}</Item>
    {/if}
  {/each}
</Container>
<Container
  itemId="words-root"
  metadata={{ boardId: "sentence" }}
  config={{ dropPriority: 2, callbacks }}
>
  {words.entries.map((entry) =>
    entry.isGhost ? (
      <Ghost key={entry.itemId} ghost={entry.ghost} />
    ) : entry.childTree ? (
      <WordContainer key={entry.itemId} entry={entry} />
    ) : (
      <Item key={entry.itemId} itemId={entry.itemId}>
        {entry.value.label}
      </Item>
    ),
  )}
</Container>

For the more common scenarios you would want to define priorities, SnapSort provided pre-made helpers you can use. See Standard Callbacks reference for their exact signatures, geometry, return values, and import path.