Ghost
On this page
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.