Ghost renders one temporary SnapSort ghost entry as a div. SnapSort creates the GhostState; the application stores that state and renders the component.

import { Ghost } from "@snap-engine/snapsort/react";
import type { GhostState } from "@snap-engine/snapsort";

function RenderGhost({ ghost }: { ghost: GhostState }) {
  return <Ghost ghost={ghost} />;
}

Component Properties

interface GhostProps extends HTMLAttributes<HTMLDivElement> {
  ghost: GhostState;
  insertionMarker?: InsertionMarkerRectOptions;
}

ghost

Type: GhostState · Required · Read-only input

The immutable state for the entry being rendered. Pass the ghost from a RenderEntry or ghost lifecycle event without constructing or modifying it. The component binds its element to ghost.ghostItem and detaches that binding when the state changes or the component unmounts.

return entry.isGhost ? <Ghost ghost={entry.ghost} /> : null;

children

Type: ReactNode · Default: no content

Custom content rendered inside the ghost div. Use ghost.type or metadata when different roles need different content.

<Ghost ghost={entry.ghost}>{entry.ghost.items.length} selected</Ghost>

insertionMarker

Type: InsertionMarkerRectOptions · Default: stockInsertionMarkerRectOptions

Controls the painted rectangle for an "insertion-marker". It has no effect on other ghost roles and never changes target ranking.

<Ghost
  ghost={entry.ghost}
  insertionMarker={{ thickness: 4, startInset: 8, endInset: 8 }}
/>

All three values must be finite and non-negative. startInset + endInset must not exceed ghost.gap.length.

className, style, id, and HTML attributes

Type: HTMLAttributes<HTMLDivElement>

The component renders a div. className is merged with snapsort-ghost and the default id is "spacer". Styles, events, ARIA attributes, and data-* attributes are forwarded. Consumer style values are spread after SnapSort’s calculated structural styles, so they intentionally override properties such as size or positioning.

<Ghost
  ghost={entry.ghost}
  className="task-ghost"
  style={{ opacity: 0.75 }}
  aria-hidden="true"
/>

ref

Type: Ref<HTMLDivElement> · Default: null

The forwarded ref receives the rendered div after mount and becomes null when it unmounts.

const ghostElement = useRef<HTMLDivElement>(null);

<Ghost ref={ghostElement} ghost={entry.ghost} />;

GhostState

Type: read-only discriminated union

Narrow ghost.type to access role-specific geometry and locations.

function inspectGhost(ghost: GhostState) {
  if (ghost.type !== "insertion-marker") return;
  console.log(ghost.gap, ghost.isCurrentPlacement);
}

type

Type: "source-spacer" | "target-spacer" | "insertion-marker" | "pointer-preview"

Identifies the role and narrows the union:

  • "source-spacer" preserves a vacated source slot.
  • "target-spacer" reserves a prospective flow-layout destination.
  • "insertion-marker" describes the resolved insertion gap.
  • "pointer-preview" represents the dragged run in an overlay.

session

Type: DragSession · Read-only

The stable session handle that owns this ghost. All ghost roles created during one gesture refer to the same handle.

const isActiveGhost = ghost.session.status === "active";

original, originalItemId, and originalMetadata

Types: Item, ItemId, and ItemMetadata · Read-only

Identify the ordinary Item represented by this ghost anchor. Use originalItemId for application identity and originalMetadata for the application data captured with the Item.

const label = String(ghost.originalMetadata.label ?? ghost.originalItemId);

items and itemIds

Types: readonly Item[] and readonly ItemId[] · Read-only

Parallel arrays describing the complete dragged run in its stable order. A single-item drag has one entry. A spacer may represent one anchor while these properties still describe every participant.

const draggedIds = ghost.itemIds.join(", ");

ghostItem, ghostItemId, and ghostMetadata

Types: Item, ItemId, and ItemMetadata · Read-only

Identify the transient core Item allocated for this rendered ghost. ghostItemId is root-unique and is the correct React key. Do not persist these values as application Items or render ghostItem through the Item component.

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

location

Type: GhostSlotLocation | GhostOverlayLocation · Read-only

Every location contains container and its read-only containerMetadata. Slot roles also contain a zero-based index; overlay roles do not.

if (ghost.location.type === "slot") {
  console.log(ghost.location.container.name, ghost.location.index);
}

source-spacer, target-spacer, and insertion-marker use GhostSlotLocation. pointer-preview uses GhostOverlayLocation.

rect

Type: GhostRect · Read-only · Spacer and pointer-preview only

A world-space rectangle with x, y, width, and height. The component uses width and height for flow spacers and converts pointer-preview coordinates into the owning Container’s local space.

if (ghost.type !== "insertion-marker") {
  console.log(`${ghost.rect.width} × ${ghost.rect.height}`);
}

gap

Type: InsertionGapSegment · Read-only · Insertion marker only

A zero-thickness world-space segment. orientation is "horizontal" or "vertical"; x and y locate its start and length measures it along the orientation’s main axis.

if (ghost.type === "insertion-marker") {
  const { orientation, length } = ghost.gap;
}

previous and next

Type: InsertionMarkerNeighbor | null · Read-only · Insertion marker only

The ordinary Items immediately adjacent to the insertion boundary. Each neighbor contains item, itemId, itemMetadata, and its frozen world-space border-box rect. Either value is null at an open edge.

if (ghost.type === "insertion-marker") {
  const beforeId = ghost.previous?.itemId ?? null;
  const afterId = ghost.next?.itemId ?? null;
}

isCurrentPlacement

Type: boolean · Read-only · Insertion marker only

true when committing the candidate would preserve the dragged run’s current placement. It can be used to tone down or hide a no-op marker.

<Ghost
  ghost={entry.ghost}
  className={
    entry.ghost.type === "insertion-marker" && entry.ghost.isCurrentPlacement
      ? "current-placement"
      : ""
  }
/>

Presentation Helpers

InsertionMarkerRectOptions

interface InsertionMarkerRectOptions {
  readonly thickness: number;
  readonly startInset: number;
  readonly endInset: number;
}

thickness expands equally around the zero-thickness gap. The insets trim the start and end: left/right for a horizontal gap and top/bottom for a vertical gap.

stockInsertionMarkerRectOptions

Type: InsertionMarkerRectOptions

The frozen options used by Ghost when insertionMarker is omitted:

const stockInsertionMarkerRectOptions = {
  thickness: 3,
  startInset: 0,
  endInset: 0,
};

insertionMarkerRect()

function insertionMarkerRect(
  marker: InsertionMarkerState,
  options: InsertionMarkerRectOptions,
): ContainerLocalRect;

Expands a marker’s world-space gap into a rectangle positioned in the destination Container’s local coordinate space. It throws for invalid option values, invalid gap geometry, or an unmounted destination Container.

const rect = insertionMarkerRect(marker, {
  thickness: 4,
  startInset: 6,
  endInset: 6,
});

toContainerLocalRect()

function toContainerLocalRect(
  worldRect: Readonly<GhostRect>,
  container: Container,
): ContainerLocalRect;

Converts a world-space rectangle to coordinates measured from a mounted Container’s padding-box outer edge. Border widths and scroll offsets are included. It throws if the Container has no mounted element.

const localRect = toContainerLocalRect(ghost.rect, ghost.location.container);

ContainerLocalRect

Type: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }

The return value of both presentation helpers, suitable for absolute positioning inside the owning Container.

DOM Contract

Every rendered element has snapsort-ghost, data-snapsort-ghost-entry={ghost.type}, and data-snapsort-ghost-item-count={ghost.items.length}. Pointer previews add data-snapsort-ghost="pointer"; insertion markers add data-snapsort-ghost="insertion".

Appendix

RenderTree Integration

Ghost lifecycle callbacks must synchronously update React state and let React reconcile the DOM. Do not use structural DOM APIs for a framework-rendered ghost.

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>
  ),
);

Ghost Roles and Positioning

Source and target spacers remain in normal flow and inherit the original Item’s measured margins. Pointer previews and insertion markers are absolute, pointer-transparent overlays in their owning Container. Pointer representation and placement feedback are independent, so one session may own both a pointer-preview and an insertion-marker.

onGhostMove reports structural relocation or changed marker presentation; it is not a continuous pointer-move stream. See Drags and Ghosts for the lifecycle and DragSession for dragVisual.