Animate & Update

AlpineFlow provides two core methods for changing node, edge, and viewport properties: update() for instant changes and animate() for smooth transitions.

<div x-data="flowCanvas({
    nodes: [
        { id: 'u1', position: { x: 30, y: 15 }, data: { label: 'update()' } },
        { id: 'a1', position: { x: 250, y: 15 }, data: { label: 'animate()' } },
    ],
    edges: [],
    background: 'dots',
    controls: false,
    pannable: false,
    zoomable: false,
})" class="flow-container" style="height: 200px;"
   x-init="
       let uDown = false, aDown = false;
       document.getElementById('demo-update-btn').addEventListener('click', () => {
           uDown = !uDown;
           $flow.update({ nodes: { u1: { position: { x: 30, y: uDown ? 80 : 15 } } } });
       });
       document.getElementById('demo-animate-btn').addEventListener('click', () => {
           aDown = !aDown;
           $flow.animate({ nodes: { a1: { position: { x: 250, y: aDown ? 80 : 15 } } } }, { duration: 600, easing: 'easeInOut' });
       });
       document.getElementById('demo-ua-reset').addEventListener('click', () => {
           uDown = false; aDown = false;
           $flow.update({ nodes: {
               u1: { position: { x: 30, y: 15 } },
               a1: { position: { x: 250, y: 15 } },
           }});
       });
   ">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node">
                <span x-text="node.data.label"></span>
            </div>
        </template>
    </div>
</div>

update() vs animate()

Both methods accept the same AnimateTargets shape and AnimateOptions, but differ in their defaults:

Method Default Duration Use Case
$flow.update() 0 (instant) Snap properties to new values without visual transition
$flow.animate() 300 ms Smoothly interpolate properties over time
// Instant — node jumps to the new position
$flow.update({ nodes: { 'node-1': { position: { x: 300, y: 200 } } } });

// Smooth — node glides to the new position over 300ms
$flow.animate({ nodes: { 'node-1': { position: { x: 300, y: 200 } } } });

// update() with an explicit duration behaves identically to animate()
$flow.update(
  { nodes: { 'node-1': { position: { x: 300, y: 200 } } } },
  { duration: 500, easing: 'easeInOut' }
);

AnimateTargets

The targets object has three optional keys — nodes, edges, and viewport:

interface AnimateTargets {
    nodes?: Record<string, AnimateNodeTarget>;
    edges?: Record<string, AnimateEdgeTarget>;
    viewport?: AnimateViewportTarget;
}

Node Targets (keyed by node ID)

Property Type Animated? Description
position { x?, y? } Yes Move node to position
dimensions { width?, height? } Yes Resize node
style string | Record<string, string> Yes CSS style interpolation

Note: Animating dimensions.height auto-sets fixedDimensions: true on the target node at animation start — the explicit height persists after the animation completes. Reset fixedDimensions to false on the node to let it return to content-driven sizing. | class | string | Instant | CSS class replacement | | data | Record<string, any> | Instant | Merge into node data | | selected | boolean | Instant | Selection state | | zIndex | number | Instant | Z-index override |

Edge Targets (keyed by edge ID)

Property Type Animated? Description
color string Yes Stroke color interpolation
strokeWidth number Yes Stroke width
label string Instant Edge label text
animated boolean Instant Dash animation toggle
class string Instant CSS class replacement

Viewport Target

Property Type Description
pan { x?, y? } Pan to position
zoom number Zoom level

AnimateOptions

interface AnimateOptions {
    // Timing
    duration?: number;    // ms. 0 = instant. Default: 300 (animate) / 0 (update)
    easing?: EasingName | ((t: number) => number);  // Default: 'easeInOut'
    delay?: number;       // ms before starting. Default: 0
    loop?: boolean | 'ping-pong';  // true = forever, 'ping-pong' = bounce back and forth
    startAt?: 'start' | 'end';  // 'end' snaps to target and plays backward. Default: 'start'.

    // Physics — use instead of duration+easing
    motion?: MotionConfig | string;   // e.g. 'spring.wobbly' or { type: 'spring', stiffness: 100 }
    maxDuration?: number;              // safety cap for physics motion (ms). Default: 5000.

    // Lifecycle callbacks
    onStart?: () => void;
    onProgress?: (progress: number) => void;
    onComplete?: () => void;

    // Tagging — group animations so `$flow.cancelAll({ tag })` can stop them together
    tag?: string;
    tags?: string[];

    // State-aware cancellation — `while:` auto-cancels when the predicate returns false
    while?: () => boolean;
    whileStopMode?: 'jump-end' | 'rollback' | 'freeze';  // how to stop. Default: 'jump-end'.
}

loop: 'reverse' still works and is aliased to 'ping-pong' for backwards compatibility.

Compare easing presets — all nodes move to the same target, each with a different curve:

<div x-data="flowCanvas({
    nodes: [
        { id: 'n1', position: { x: 10, y: 10 }, data: { label: 'linear' } },
        { id: 'n2', position: { x: 10, y: 60 }, data: { label: 'easeInOut' } },
        { id: 'n3', position: { x: 10, y: 110 }, data: { label: 'easeBounce' } },
        { id: 'n4', position: { x: 10, y: 160 }, data: { label: 'easeElastic' } },
    ],
    edges: [],
    background: 'dots',
    controls: false,
    pannable: false,
    zoomable: false,
})" class="flow-container" style="height: 280px;"
   x-init="
       const startX = 10, endX = 300, dur = 1500;
       let atEnd = false;
       document.getElementById('demo-easing-play').addEventListener('click', () => {
           const targetX = atEnd ? startX : endX;
           $flow.animate({ nodes: { n1: { position: { x: targetX } } } }, { duration: dur, easing: 'linear' });
           $flow.animate({ nodes: { n2: { position: { x: targetX } } } }, { duration: dur, easing: 'easeInOut' });
           $flow.animate({ nodes: { n3: { position: { x: targetX } } } }, { duration: dur, easing: 'easeBounce' });
           $flow.animate({ nodes: { n4: { position: { x: targetX } } } }, { duration: dur, easing: 'easeElastic' });
           atEnd = !atEnd;
       });
       document.getElementById('demo-easing-reset').addEventListener('click', () => {
           atEnd = false;
           $flow.update({ nodes: {
               n1: { position: { x: startX, y: 10 } },
               n2: { position: { x: startX, y: 60 } },
               n3: { position: { x: startX, y: 110 } },
               n4: { position: { x: startX, y: 160 } },
           }});
       });
   ">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node">
                <span x-text="node.data.label" style="font-family: monospace; font-size: 12px;"></span>
            </div>
        </template>
    </div>
</div>

FlowAnimationHandle

Both update() and animate() return a FlowAnimationHandle for controlling the in-flight animation:

interface FlowAnimationHandle {
    // Transport
    pause(): void;
    resume(): void;
    play(): void;                        // revive a finished handle and play from current position
    playForward(): void;                  // set direction 'forward' and play
    playBackward(): void;                 // set direction 'backward' and play
    reverse(): void;                      // flip direction and keep playing
    restart(options?: { direction?: 'forward' | 'backward' }): void;
    stop(options?: StopOptions): void;    // see "Stop modes" below

    // State (all readonly)
    readonly direction: 'forward' | 'backward';
    readonly isFinished: boolean;
    readonly currentValue: Map<string, number | string>;  // current per-key interpolated values
    readonly finished: Promise<void>;
}

interface StopOptions {
    mode?: 'jump-end' | 'rollback' | 'freeze';  // default 'jump-end'
}
const handle = $flow.animate({
    nodes: {
        'node-1': { position: { x: 300, y: 100 } },
        'node-2': { position: { x: 500, y: 200 }, style: { opacity: '0.8' } },
    },
    edges: {
        'edge-1': { color: '#10b981', strokeWidth: 3 },
    },
}, {
    duration: 500,
    easing: 'easeOut',
    onComplete: () => console.log('done'),
});

// Control the animation
handle.pause();
handle.resume();

Start a slow animation, then use the controls to pause, resume, reverse, or stop it. Buttons are disabled when they'd be a no-op (e.g. Start while running, Reverse while idle):

<div x-data="flowCanvas({
    nodes: [
        { id: 'mover', position: { x: 0, y: 50 }, data: { label: 'Controlled' } },
    ],
    edges: [],
    background: 'dots',
    controls: false,
    pannable: false,
    zoomable: false,
})" class="flow-container" style="height: 220px;"
   x-init="
       let handle = null;
       let paused = false;
       const startPos = { x: 0, y: 50 };
       const endPos = { x: 400, y: 50 };
       const btns = {
           start:   document.getElementById('demo-handle-start'),
           pause:   document.getElementById('demo-handle-pause'),
           resume:  document.getElementById('demo-handle-resume'),
           reverse: document.getElementById('demo-handle-reverse'),
           stop:    document.getElementById('demo-handle-stop'),
       };
       const syncButtons = () => {
           const running = !!handle && !handle.isFinished;
           btns.start.disabled   = running;
           btns.pause.disabled   = !running || paused;
           btns.resume.disabled  = !running || !paused;
           btns.reverse.disabled = !running;
           btns.stop.disabled    = !running;
       };
       btns.start.addEventListener('click', () => {
           if (handle && !handle.isFinished) return;
           $flow.update({ nodes: { mover: { position: startPos } } });
           paused = false;
           handle = $flow.animate(
               { nodes: { mover: { position: endPos } } },
               { duration: 3000, easing: 'linear', onComplete: syncButtons },
           );
           syncButtons();
       });
       btns.pause.addEventListener('click',   () => { handle?.pause();   paused = true;  syncButtons(); });
       btns.resume.addEventListener('click',  () => { handle?.resume();  paused = false; syncButtons(); });
       btns.reverse.addEventListener('click', () => handle?.reverse());
       btns.stop.addEventListener('click',    () => {
           handle?.stop();
           $flow.update({ nodes: { mover: { position: startPos } } });
           handle = null;
           paused = false;
           syncButtons();
       });
   ">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node">
                <span x-text="node.data.label"></span>
            </div>
        </template>
    </div>
</div>

Stop modes

handle.stop() and $flow.cancelAll(filter) both accept a mode option that decides what the final visual state looks like when the animation ends:

Mode Behavior
'jump-end' (default) Snap to target values — the animation completes instantly
'rollback' Revert to the values captured when the animation started
'freeze' Leave nodes at whatever interpolated value they happen to be at
// Snap the node to its destination
handle.stop({ mode: 'jump-end' });

// Revert to the starting values
handle.stop({ mode: 'rollback' });

// Leave it wherever it is — useful for user-cancellation UX
handle.stop({ mode: 'freeze' });

This matters for UX choices — "user cancelled, keep their in-progress position" is freeze; "operation failed, undo everything we changed" is rollback; "success, commit the destination" is jump-end.

Start three 4-second animations, then click "Stop all" — each one uses a different mode so you can see them side-by-side:

<div x-data="flowCanvas({
    nodes: [
        { id: 'jump',   position: { x: 40, y: 20  }, data: { label: 'jump-end  → snaps to target' } },
        { id: 'roll',   position: { x: 40, y: 80  }, data: { label: 'rollback  → reverts to start' } },
        { id: 'freeze', position: { x: 40, y: 140 }, data: { label: 'freeze    → stays mid-flight' } },
    ],
    edges: [],
    background: 'dots',
    controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 220px;"
   x-init="
       let handles = {};
       const startAll = () => {
           $flow.update({ nodes: {
               jump:   { position: { x: 40 } },
               roll:   { position: { x: 40 } },
               freeze: { position: { x: 40 } },
           }});
           handles.jump   = $flow.animate({ nodes: { jump:   { position: { x: 360 } } } }, { duration: 4000, easing: 'linear' });
           handles.roll   = $flow.animate({ nodes: { roll:   { position: { x: 360 } } } }, { duration: 4000, easing: 'linear' });
           handles.freeze = $flow.animate({ nodes: { freeze: { position: { x: 360 } } } }, { duration: 4000, easing: 'linear' });
       };
       document.getElementById('demo-stopmode-start').addEventListener('click', startAll);
       document.getElementById('demo-stopmode-stop').addEventListener('click', () => {
           handles.jump?.stop({ mode: 'jump-end' });
           handles.roll?.stop({ mode: 'rollback' });
           handles.freeze?.stop({ mode: 'freeze' });
       });
   ">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node">
                <span x-text="node.data.label" style="font-family: monospace; font-size: 11px;"></span>
            </div>
        </template>
    </div>
</div>

Transactions

A transaction wraps several animations so you can roll them all back as a unit:

const tx = $flow.transaction(async () => {
    $flow.animate({ nodes: { a: { position: { x: 360 } } } }, { duration: 2500 });
    await new Promise(r => setTimeout(r, 500));
    $flow.animate({ nodes: { b: { position: { x: 360 } } } }, { duration: 2500 });
    await new Promise(r => setTimeout(r, 500));
    $flow.animate({ nodes: { c: { position: { x: 360 } } } }, { duration: 2500 });
});

// Sometime later — revert everything the transaction animated
tx.rollback();

tx.rollback() stops every tracked animation with 'freeze' mode, then re-applies the pre-transaction values for every property that was touched. The canvas ends up exactly where it was before the transaction started, even for animations that had already completed.

tx.commit() is the no-op counterpart — it marks the transaction as finalized and resolves tx.finished. Async fn returns commit automatically when it resolves; thrown errors auto-rollback.

Start the transaction, then click rollback mid-sequence — all three nodes freeze in place, then snap back to the origin:

<div x-data="flowCanvas({
    nodes: [
        { id: 'a', position: { x: 40, y: 20  }, data: { label: 'A' } },
        { id: 'b', position: { x: 40, y: 80  }, data: { label: 'B' } },
        { id: 'c', position: { x: 40, y: 140 }, data: { label: 'C' } },
    ],
    edges: [],
    background: 'dots',
    controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 220px;"
   x-init="
       let tx = null;
       document.getElementById('demo-tx-run').addEventListener('click', () => {
           $flow.update({ nodes: {
               a: { position: { x: 40 } },
               b: { position: { x: 40 } },
               c: { position: { x: 40 } },
           }});
           tx = $flow.transaction(async () => {
               $flow.animate({ nodes: { a: { position: { x: 360 } } } }, { duration: 2500, easing: 'linear' });
               await new Promise(r => setTimeout(r, 500));
               $flow.animate({ nodes: { b: { position: { x: 360 } } } }, { duration: 2500, easing: 'linear' });
               await new Promise(r => setTimeout(r, 500));
               $flow.animate({ nodes: { c: { position: { x: 360 } } } }, { duration: 2500, easing: 'linear' });
           });
       });
       document.getElementById('demo-tx-rollback').addEventListener('click', () => tx?.rollback());
   ">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node">
                <span x-text="node.data.label"></span>
            </div>
        </template>
    </div>
</div>

Groups

Tag multiple animations with a shared name so you can stop, pause, or resume them together:

const ambient = $flow.group('ambient');

ambient.animate({ nodes: { g1: { position: { x: 360 } } } }, { duration: 5000 });
ambient.animate({ nodes: { g2: { position: { x: 360 } } } }, { duration: 5000 });
ambient.animate({ nodes: { g3: { position: { x: 360 } } } }, { duration: 5000 });

// Later — stop every handle tagged 'ambient'
ambient.cancelAll({ mode: 'rollback' });
ambient.pauseAll();
ambient.resumeAll();

FlowGroup auto-tags every animation it creates, so callers never touch the tag option directly. You can still tag animations manually via AnimateOptions.tag / .tags and use $flow.cancelAll({ tag: 'ambient' }) for the same effect.

<div x-data="flowCanvas({
    nodes: [
        { id: 'g1', position: { x: 40, y: 20  }, data: { label: 'g1 (ambient)' } },
        { id: 'g2', position: { x: 40, y: 80  }, data: { label: 'g2 (ambient)' } },
        { id: 'g3', position: { x: 40, y: 140 }, data: { label: 'g3 (ambient)' } },
    ],
    edges: [],
    background: 'dots',
    controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 220px;"
   x-init="
       let group = null;
       document.getElementById('demo-group-start').addEventListener('click', () => {
           $flow.update({ nodes: {
               g1: { position: { x: 40 } },
               g2: { position: { x: 40 } },
               g3: { position: { x: 40 } },
           }});
           group = $flow.group('ambient');
           group.animate({ nodes: { g1: { position: { x: 360 } } } }, { duration: 5000, easing: 'linear' });
           group.animate({ nodes: { g2: { position: { x: 360 } } } }, { duration: 5000, easing: 'linear' });
           group.animate({ nodes: { g3: { position: { x: 360 } } } }, { duration: 5000, easing: 'linear' });
       });
       document.getElementById('demo-group-rollback').addEventListener('click', () => group?.cancelAll({ mode: 'rollback' }));
       document.getElementById('demo-group-jump').addEventListener('click', () => group?.cancelAll());
   ">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node">
                <span x-text="node.data.label"></span>
            </div>
        </template>
    </div>
</div>

State-aware cancellation

Sometimes an animation should stop when some state flips — e.g. "run this pulse while the node is hovered." Pass a while: predicate that the engine evaluates once per frame:

let hovering = true;

$flow.animate(
    { nodes: { n: { position: { x: 360 } } } },
    {
        duration: 6000,
        easing: 'linear',
        while: () => hovering,
        whileStopMode: 'freeze',   // stay where the animation was when predicate flipped
    },
);

// Later — flip the predicate
hovering = false;
// animation auto-terminates on the next frame with 'freeze' stop mode

while: is terminal — when the predicate returns false, the animation stops (with whileStopMode) and the handle ends. It's not a pause/resume gate; toggling the predicate back to true won't resume the animation. Think of it as a kill switch that fires when a condition changes.

active = true
<div x-data="flowCanvas({
    nodes: [
        { id: 'w', position: { x: 40, y: 80 }, data: { label: 'Animates while active' } },
    ],
    edges: [],
    background: 'dots',
    controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 200px;"
   x-init="
       let active = true;
       const label = document.getElementById('demo-while-state');
       document.getElementById('demo-while-run').addEventListener('click', () => {
           active = true;
           label.textContent = 'active = true';
           $flow.update({ nodes: { w: { position: { x: 40 } } } });
           $flow.animate(
               { nodes: { w: { position: { x: 360 } } } },
               { duration: 6000, easing: 'linear', while: () => active, whileStopMode: 'freeze' },
           );
       });
       document.getElementById('demo-while-toggle').addEventListener('click', () => {
           active = !active;
           label.textContent = 'active = ' + active + (active ? '' : ' → animation terminates next frame');
       });
   ">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node">
                <span x-text="node.data.label"></span>
            </div>
        </template>
    </div>
</div>

Direction state machine

A FlowAnimationHandle carries a direction ('forward' or 'backward') that controls which way it plays. The control methods adjust both direction and playback state:

Call Effect
handle.play() Revive (if finished) and play from the current value in the current direction
handle.playForward() Set direction = 'forward' and play
handle.playBackward() Set direction = 'backward' and play
handle.reverse() Flip direction and keep playing
handle.restart({ direction }) Jump to the appropriate end and play in the given direction (defaults to the current one)
const handle = $flow.animate({ nodes: { n: { position: { x: 360 } } } }, { duration: 1500 });
await handle.finished;

handle.playBackward();        // replay in reverse without resetting
handle.playForward();         // play forward again from current position
handle.restart({ direction: 'backward' });  // jump to the end and play backward

This is the difference between reverse() (which always flips the current direction) and playBackward() (which forces backward regardless of prior direction). restart() is useful for "rewind and go" UX — it snaps to the start of the chosen direction before playing.

Per-Element Timing Overrides

Individual targets can override the global duration and easing using _duration and _easing:

$flow.animate({
    nodes: {
        'fast-node': { position: { x: 100 }, _duration: 200 },
        'slow-node': { position: { x: 500 }, _duration: 1000 },
    },
}, { duration: 500 }); // default for targets without _duration

A target with _duration: 0 applies its changes instantly while other targets animate.

Named Animations

Register reusable animation sequences by name, then trigger them from anywhere:

// Register
$flow.registerAnimation('intro', [
    { nodes: ['a', 'b', 'c'], position: { x: 0, y: 0 }, duration: 0 },
    { nodes: ['a'], position: { x: 100, y: 50 }, duration: 500 },
    { nodes: ['b'], position: { x: 300, y: 50 }, duration: 500 },
    { nodes: ['c'], position: { x: 200, y: 200 }, duration: 500 },
]);

// Play
await $flow.playAnimation('intro');

// Unregister when no longer needed
$flow.unregisterAnimation('intro');

Named animations are also the mechanism behind the x-flow-animate directive's argument syntax:

<div x-flow-animate:intro="introSteps"></div>
<button @click="$flow.playAnimation('intro')">Play Intro</button>

x-flow-animate Directive

Trigger one-shot animations on nodes, edges, or the viewport from DOM events.

Basic Usage

<button x-flow-animate="{ nodes: ['node-1'], position: { x: 300, y: 100 }, duration: 500, easing: 'easeInOut' }">
    Move Node
</button>

Step Shape

{
    nodes: ['id'],           // target node IDs
    edges: ['id'],           // target edge IDs
    viewport: true,          // target the viewport

    // Node properties
    position: { x, y },
    dimensions: { width, height },
    style: { ... },
    class: 'name',
    data: { key: value },
    selected: true,
    zIndex: 10,

    // Edge properties
    color: '#ff0000',
    strokeWidth: 3,
    label: 'new label',
    animated: true,
    class: 'name',

    // Viewport properties
    pan: { x, y },
    zoom: 1.5,

    // Timing
    duration: 500,
    easing: 'easeInOut',
    delay: 0,
}

Sequential Steps

When an array of steps is provided, they execute sequentially:

<button x-flow-animate="[
    { nodes: ['a'], position: { x: 100, y: 0 }, duration: 300 },
    { nodes: ['b'], position: { x: 200, y: 0 }, duration: 300 },
]">
    Move A then B
</button>

Named Animations via Argument

Use :name to register a named animation that can be triggered programmatically via playAnimation():

<div x-flow-animate:intro="[
    { nodes: ['a', 'b'], position: { x: 0, y: 0 }, duration: 600 },
    { viewport: true, zoom: 1, duration: 400 },
]"></div>
$flow.playAnimation('intro');

Modifiers

Modifier Description
.click Trigger on click (default).
.mouseenter Trigger on mouse enter.
.once Play the animation only once; subsequent triggers are ignored.
.reverse Automatically reverse the animation when triggered again.
.queue Queue the animation instead of cancelling the previous one.

Examples

Mouseenter with auto-reverse -- nodes move to the target position on hover; triggering again returns them:

<div x-flow-animate.mouseenter.reverse="{
    nodes: ['preview'],
    position: { x: 0, y: -50 },
    style: { opacity: '1' },
    duration: 300,
}">
    Hover to preview
</div>

One-shot intro:

<button x-flow-animate.click.once="[
    { nodes: ['step-1'], style: { opacity: '1' }, duration: 400 },
    { nodes: ['step-2'], style: { opacity: '1' }, duration: 400 },
    { viewport: true, zoom: 1, pan: { x: 0, y: 0 }, duration: 600 },
]">
    Play Intro
</button>

Demo

Hover over each node — it animates on mouse enter and reverses on mouse leave. Shown as an imperative pattern; the x-flow-animate directive above is the declarative equivalent.

INTERACTIVE
<div x-data="flowCanvas({
    nodes: [
        { id: 'a', position: { x: 20,  y: 60 }, data: { label: 'Hover me', baseY: 60 } },
        { id: 'b', position: { x: 220, y: 0  }, data: { label: 'Or me',   baseY: 0  } },
        { id: 'c', position: { x: 420, y: 60 }, data: { label: 'Or me',   baseY: 60 } },
    ],
    edges: [
        { id: 'e1', source: 'a', target: 'b' },
        { id: 'e2', source: 'b', target: 'c' },
    ],
    background: 'dots',
    fitViewOnInit: true,
    controls: false,
    pannable: false,
    zoomable: false,
})" class="flow-container" style="height: 230px;">
    <div x-flow-viewport>
        <template x-for="node in nodes" :key="node.id">
            <div x-flow-node="node"
                 @mouseenter="$flow.animate({ nodes: { [node.id]: { position: { y: node.data.baseY - 10 } } } }, { duration: 200, easing: 'easeOut' })"
                 @mouseleave="$flow.animate({ nodes: { [node.id]: { position: { y: node.data.baseY      } } } }, { duration: 200, easing: 'easeOut' })">
                <div x-flow-handle:target></div>
                <span x-text="node.data.label"></span>
                <div x-flow-handle:source></div>
            </div>
        </template>
    </div>
</div>