Coordinating Svelte Animations With XState
Svelte handles DOM synchronization natively, but when application state becomes tangled—especially around coordinated animations—managing it with ad hoc variables and callbacks gets messy fast. XState's @xstate/fsm package offers a structured way to model state as a finite state machine (FSM). It won’t replace Svelte's reactivity, but it centralizes the logic that decides what happens, when, and why.
Consider an autocomplete widget. It animates a results list in when focused, animates the list's dimensions as the user types and filters, and animates the list out (shrinking and fading) when the input loses focus or ESC is pressed. Ideally, the list is removed from the DOM only after the closing animation finishes. Different spring configurations are used for opening vs. closing to make the dismissal feel snappier.
Svelte transitions could handle entering and leaving the DOM, but we’re also animating the list’s height while it’s open, as the user filters. Coordinating Svelte transitions with ongoing spring animations is harder than simply telling a spring to animate to zero and then removing the element. That still leaves the state logic scattered across event handlers, which is where it gets fragile.
In the original implementation, this requires tracking four distinct concerns:
open: whether the list is shownresultsListVisible: whether the list is in the DOM (set tofalseonly after the closing animation resolves)closing: whether the list is mid-close animation (so focus can reverse it)setSpringDimensions: a function called from four different places that configures the springs based on whether the list is opening, closing, or resizing
A ResizeObserver triggers setSpringDimensions on list size changes. The mental model jumps around the file: each piece of state is read and mutated in multiple event handlers, and tracking which one does what becomes the source of bugs.
A real bug surfaced: the ESC key handler set open to false but forgot to set closing to true and to call setSpringDimensions(false, true). The fix is trivial but the architecture is the problem—this kind of state is easy to miss because it’s split across handlers.
Modeling with an FSM
XState does not remove the complexity of coordinating springs and DOM removal. It gives you a single, declarative place to define states, events, transitions, and side effects. The minimal @xstate/fsm package ships about 1KB and covers the semantics of primitive FSMs without the advanced features of the full XState library—more than enough for this use case.
The machine definition looks like this:
const stateMachine = createMachine(
{
initial: "initial",
context: {
open: false,
node: null
},
states: {
initial: {
on: { OPEN: "open" }
},
open: {
on: {
RENDERED: { actions: "rendered" },
RESIZE: { actions: "resize" },
CLOSE: "closing"
},
entry: "opened"
},
closing: {
on: {
OPEN: { target: "open", actions: ["resize"] },
CLOSED: "closed"
},
entry: "close"
},
closed: {
on: {
OPEN: "open"
},
entry: "closed"
}
}
},
{
actions: {
opened: assign(context => {
return { ...context, open: true };
}),
rendered: assign((context, evt) => {
const { node } = evt;
return { ...context, node };
}),
close() {},
resize(context) {},
closed: assign(() => {
return { open: false, node: null };
})
}
}
);
The initial property sets the starting state (named “initial”). context stores machine data—in this case a boolean for the results list’s open state and a node object referencing the results list DOM element.
Each key under states defines a machine state. For most states, there’s an on property (event handlers) and an entry property (side effects run whenever the state is entered). Transitions from state to state are configured in on: for instance, firing the OPEN event from the “initial” state moves to the “open” state, and firing OPEN from the “closing” state transitions to “open” and runs the resize action. The entry field triggers an action automatically when the state is reached.
Actions fall into two categories:
- Context updates: wrapped in
assign, returning new context data. - Side effects: plain functions that perform work without updating context.
Driving the machine
The interpret function runs the machine:
const stateMachineService = interpret(stateMachine).start();
The returned service object accepts events via send. In the Svelte action that runs when the results list mounts, it dispatches the RENDERED event with the DOM node as payload:
stateMachineService.send({ type: "RENDERED", node });
Across the component, ad hoc state assignments collapse to single event dispatches. The input’s focus/click handler fires OPEN; the ResizeObserver fires RESIZE; the blur and ESC handlers fire CLOSE.
This provides immediate behavioral guarantees. Before, clicking an already-open input re-ran the open logic; harmless but unnecessary. With the machine, firing OPEN from the “open” state does nothing because no such transition is configured. The special case of re-opening the list while it’s closing is encoded once in the machine definition, not in a conditional inside the event handler.
Centralizing the animation logic
The original inputEngaged handler looked like this:
function inputEngaged(evt) {
if (closing) {
setSpringDimensions();
}
open = true;
resultsListVisible = true;
}
It had to detect the closing state and force a spring recalculation manually. The XState version removes that branch entirely:
function inputEngaged(evt) {
stateMachineService.send("OPEN");
}
That same handler—with all the edge-case handling stripped out—now dispatches a single event.
The bulk of the animation coordination moves into the action definitions, where each action is scoped to a machine context:
{
actions: {
opened: assign({ open: true }),
rendered: assign((context, evt) => {
const { node } = evt;
const dimensions = getResultsListDimensions(node);
itemsHeightObserver.observe(node);
opacitySpring.set(1, { hard: true });
Object.assign(slideInSpring, SLIDE_OPEN);
slideInSpring.update(prev => ({ ...prev, width: dimensions.width }), {
hard: true
});
slideInSpring.set(dimensions, { hard: false });
return { ...context, node };
}),
close() {
opacitySpring.set(0);
Object.assign(slideInSpring, SLIDE_CLOSE);
slideInSpring
.update(prev => ({ ...prev, height: 0 }))
.then(() => {
stateMachineService.send("CLOSED");
});
},
resize(context) {
opacitySpring.set(1);
slideInSpring.set(getResultsListDimensions(context.node));
},
closed: assign(() => {
itemsHeightObserver.unobserve(resultsList);
return { open: false, node: null };
})
}
}
Nothing changes about the underlying spring mechanics—the goal is to organize where and when they are applied. XState substitutes conditional logic and scattered variable mutations with deterministic transitions defined in one place.
Synchronizing back to Svelte
The state machine service exposes a subscribe method, which fires on every state change with the current machine state and its context. Svelte’s reactive $: syntax works with any object that has a subscribe method—not just Svelte stores—so extracting state into component variables is straightforward:
$: ({ open, node: resultsList } = $stateMachineService.context);
This destructures the context directly into reactive variables. The component re-renders whenever the machine context updates.
The implementation consolidates logic with some trade-offs: certain actions both mutate context and trigger side effects, which in a stricter design would be split into separate assign and effect actions. For an introductory example, keeping them together reduces boilerplate without eliminating the benefit of the state machine.



