Skip to content

Instantly share code, notes, and snippets.

@PatrickJS
Last active June 23, 2026 07:28
Show Gist options
  • Select an option

  • Save PatrickJS/1c6f845a628af95649d6647e207d141a to your computer and use it in GitHub Desktop.

Select an option

Save PatrickJS/1c6f845a628af95649d6647e207d141a to your computer and use it in GitHub Desktop.

Flow drag/drop shape

Use @async/flow for the discrete lifecycle and drop rules. Keep high-frequency pointer coordinates in plain signals or DOM-local state.

import {
  can,
  compose,
  every,
  flow,
  guard,
  matches,
  not,
  set,
  status
} from "@async/flow";

const cards = {
  async move({ cardId, fromColumnId, toColumnId }) {
    return { cardId, fromColumnId, toColumnId };
  }
};

const clearDrag = set({
  phase: "idle",
  cardId: null,
  fromColumnId: null,
  overColumnId: null
});

export const cardDrag = flow(
  {
    context() {
      return { cards };
    }
  },
  {
    store: {
      phase: status("idle", ["idle", "dragging", "overTarget", "dropping"]),
      cardId: null,
      fromColumnId: null,
      overColumnId: null,

      dragging: matches("phase", ["dragging", "overTarget"]),
      overTarget: matches("phase", "overTarget"),
      dropReady: every(
        matches("phase", "overTarget"),
        (store) => store.cardId,
        (store) => store.fromColumnId,
        (store) => store.overColumnId
      ),
      canDrop: can("drop"),
      dropBlocked: not(can("drop"))
    },

    on: {
      start: guard(
        (_store, input) => Boolean(input?.cardId && input?.columnId),
        set({
          phase: "dragging",
          cardId: (_store, input) => input.cardId,
          fromColumnId: (_store, input) => input.columnId,
          overColumnId: null
        }),
        { reason: "missing_drag_source", label: "Start card drag" }
      ),

      over: guard(
        (store, input) =>
          store.phase !== "idle" &&
          Boolean(input?.columnId) &&
          store.fromColumnId !== input.columnId,
        set({
          phase: "overTarget",
          overColumnId: (_store, input) => input.columnId
        }),
        { reason: "same_column", label: "Choose another column" }
      ),

      leave: guard(
        (store) => store.phase === "overTarget",
        set({
          phase: "dragging",
          overColumnId: null
        })
      ),

      drop: guard(
        every(
          matches("phase", "overTarget"),
          (store) => store.cardId,
          (store) => store.fromColumnId,
          (store) => store.overColumnId
        ),
        compose([
          set("phase", "dropping"),
          async function moveCard(store) {
            await this.cards.move({
              cardId: store.cardId,
              fromColumnId: store.fromColumnId,
              toColumnId: store.overColumnId
            });
          },
          clearDrag
        ]),
        { reason: "no_drop_target", label: "Drop card" }
      ),

      cancel: clearDrag
    }
  }
);

Flat public reads stay value-like:

cardDrag.phase; // "idle" | "dragging" | "overTarget" | "dropping"
cardDrag.dropReady; // boolean
cardDrag.subscribe("dropReady", (ready) => {
  updateDropIndicator(ready);
});

Mounted under the name cardDrag, Async markup can pass dataset input directly:

<article
  draggable="true"
  data-card-id="card-1"
  data-column-id="todo"
  on:dragstart="cardDrag.start($dataset)"
  on:dragend="cardDrag.cancel"
  class:dragging="cardDrag.dragging"
>
  Card title
</article>

<section
  data-column-id="done"
  on:dragover="preventDefault; cardDrag.over($dataset)"
  on:dragleave="cardDrag.leave"
  on:drop="preventDefault; cardDrag.drop"
  class:drop-target="cardDrag.overTarget"
  class:drop-ready="cardDrag.dropReady"
>
  Done
</section>

Lifecycle:

idle -> dragging -> overTarget -> dropping -> idle

Flow owns the named events, store writes, guards, and published store values. The DOM owns drag browser events and visual bindings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment