Animations
SmarkForm is markup-agnostic and deliberately provides no built-in animation engine β transitions are a design concern that belongs to your CSS.
The technique is straightforward: use SmarkFormβs lifecycle events to add and remove CSS classes on list items, and let CSS transition do the rest.
Simple Example β Two-Class Symmetric Animation
The simplest approach uses two CSS classes: a base class that defines the off-screen position and the transition, and a visible class that overrides the position to the final on-screen state.
Entry animation: add the base class β wait 1 ms for the browser to paint β add the visible class. Exit animation: remove the visible class β the base class takes over β the transition plays in reverse.
<div id="myForm">
<button data-smark='{"action":"addItem","context":"items"}' title="Add">β Add</button>
<button data-smark='{"action":"removeItem","context":"items"}' title="Remove">β Remove</button>
<ul data-smark='{"type":"list","name":"items","min_items":0}'>
<li>
<input data-smark type="text" placeholder="Type something...">
</li>
</ul>
</div>
#myForm ul { list-style: none; padding: 0; }
#myForm li.anim-base {
transform: translateX(-100%);
opacity: 0;
transition: transform 200ms ease-out, opacity 200ms ease-out;
}
#myForm li.anim-base.anim-visible {
transform: translateX(0);
opacity: 1;
transition: transform 200ms ease-in, opacity 200ms ease-in;
}
const myForm = new SmarkForm(document.getElementById("myForm"));const delay = ms => new Promise(r => setTimeout(r, ms));
myForm.onAll("afterRender", async (ev) => {
if (ev.context.parent?.options.type !== "list") return;
const item = ev.context.targetNode;
item.classList.add("anim-base");
await delay(1); // Important: let the browser paint the initial state
item.classList.add("anim-visible");
});
myForm.onAll("beforeUnrender", async (ev) => {
if (ev.context.parent?.options.type !== "list") return;
const item = ev.context.targetNode;
item.classList.remove("anim-visible");
await delay(200);
});
Add items to see them slide in from the left; remove items to see them slide back out.
Why the 1 ms delay in afterRender?
CSS transitions only fire when a property changes after the element is already in the document. If both anim-base and anim-visible were added in the same microtask, the browser would never observe the initial hidden state and the transition would not play. The await delay(1) yields control for one event-loop tick, giving the rendering engine a chance to paint the initial state before the visible class is applied.
Why add anim-base via JavaScript instead of in the HTML?
If the class were baked into the template, every item would start hidden even when JavaScript is unavailable. Adding it through afterRender ensures the animation only kicks in when JS is active, so the form degrades gracefully without it.
Why await in beforeUnrender?
SmarkForm awaits the return value of beforeUnrender handlers before detaching the element from the DOM. By resolving the promise after the CSS transition-duration, we keep the element visible just long enough for the exit animation to finish.
Advanced Example β Asymmetric Entry and Exit
The two-class approach always plays the same transition in reverse for exit. Sometimes you want different entry and exit effects β for example, sliding in from the left but fading out on removal.
This needs a third class for the exit state. afterRender handles entry with a 1 ms delay so the browser can register the starting position; beforeUnrender applies the exit class and waits for its CSS transition before SmarkForm removes the item.
<div id="myForm">
<button data-smark='{"action":"addItem","context":"tasks","hotkey":"+"}' title="Add task">β Add task</button>
<button data-smark='{"action":"removeItem","context":"tasks","hotkey":"Delete","preserve_non_empty":true}' title="Remove empty">π§Ή</button>
<ul data-smark='{"type":"list","name":"tasks","min_items":1}'>
<li>
<label data-smark>
<span data-smark='{"action":"position"}'>N</span>
<input data-smark type="text" name="title" placeholder="Task description...">
</label>
<button data-smark='{"action":"removeItem","hotkey":"-"}' title="Remove">β</button>
</li>
</ul>
</div>
#myForm ul { list-style: none; padding: 0; }
#myForm li {
display: flex; align-items: center; gap: 0.4em;
margin: 0.2em 0;
}
#myForm li label { flex: 1; }
#myForm li.adv-in {
transform: translateX(-100%);
transition: transform 300ms ease-out;
}
#myForm li.adv-visible {
transform: translateX(0);
}
#myForm li.adv-out {
opacity: 0;
transition: opacity 250ms ease-in;
}
#myForm button { position: relative; }
#myForm [data-hotkey]::after {
content: "Ctrl+" attr(data-hotkey);
position: absolute; top: 0.75em; left: 0;
font-size: .7em; background: #333; color: #fff;
padding: 1px 4px; border-radius: 3px; white-space: nowrap;
}
const myForm = new SmarkForm(document.getElementById("myForm"));const delay = ms => new Promise(r => setTimeout(r, ms));
myForm.onAll("afterRender", async (ev) => {
if (ev.context.parent?.options.type !== "list") return;
const item = ev.context.targetNode;
item.classList.add("adv-in");
await delay(1);
item.classList.add("adv-visible");
});
myForm.onAll("beforeUnrender", async (ev) => {
if (ev.context.parent?.options.type !== "list") return;
const item = ev.context.targetNode;
item.classList.add("adv-out");
await delay(250);
});
Entry slides in from the left (translateX only, 300 ms). Exit fades out (opacity only, 250 ms) β the two effects are independent. Hold Ctrl to reveal the Add/Remove hotkeys.
Why separate effects instead of symmetric?
A symmetric effect (slide right for exit) puts the slowest part of the ease-in curve near the off-screen position, where the element is already nearly invisible. An opacity-only exit is simpler and looks cleaner. You can also compose both (transform + opacity) for a combined effect β the key is that the three-class approach lets you pick any combination.
Why does exit wait for 250 ms?
When adding adv-out, the item already has adv-visible establishing its onscreen state, so the opacity transition has a starting value. The 250 ms wait matches the CSS transition duration and keeps the node in the DOM until the fade completes.
Lifecycle Events for Entry/Exit Animations
-
afterRenderβ fires after a new itemβs DOM node has been inserted. Add an initial CSS class that hides or offsets the element, then β after a minimal delay to let the browser paint the initial state β add a second class that transitions it to its final visible position. -
beforeUnrenderβ fires before an item is removed from the DOM. Remove the βvisibleβ class (or add an exit class) and return aPromisethat resolves after the transition duration. SmarkForm awaits that promise, so the element stays in the document long enough for the exit animation to complete.
Global vs. Per-List Application
myForm.onAll() listens on all components in the form. By adding a guard that checks ev.context.parent?.options.type !== "list", you can skip anything that is not a direct child of a list β subforms, labels, buttons, etc.
The result is that any list added anywhere in the form hierarchy is automatically animated without further wiring.
See also: Events for the full lifecycle event reference.