Optimistic UI & Motion¶
A storefront feels slow long before it is slow. Clicking + on a cart row and watching the number sit still for 400 ms while a request travels is the difference between an interface that responds and one that waits β and no amount of server tuning removes the round trip.
MageObsidian Components answers it the other way round: apply the change on screen immediately, send the request, and reconcile when the server answers. The state layer and the event bus already provide everything this needs β a reactive local copy, a snapshot to go back to, and a phase to hang the spinner on.
The flag¶
mage_obsidian/storefront/optimistic_ui, default Yes, scoped per website and store view. Turned off, quantity and removal wait for the server before touching the screen β the animations still play, just when the response lands.
The flag reaches the browser as window.__MAGE_OBSIDIAN_UX__, emitted inline through SecureHtmlRenderer so it carries the CSP nonce, and is read with:
Adding to cart is optimistic regardless of the flag. The badge going up is the acknowledgement of the click, and there is no state to corrupt: the count is projected forward and the mandatory section reload replaces it with the server's number a moment later.
How reconciliation works¶
Three pieces of the section store, all on useCustomerData:
| Member | Purpose |
|---|---|
patch(name, partial) |
merge a partial section into the reactive map |
snapshot() |
the current map, to go back to |
restore(snapshot) |
put it back |
patch is memory-only β it never writes to mage-cache-storage. Magento's customer-data.js stays the owner of the canonical cache, so a projection that turns out to be wrong dies with the page instead of outliving it in local storage.
Two flows come out of that:
Add needs no rollback. post() already reloads the cart and messages sections on completion, and that reload is the reconciliation β it overwrites the projection with the truth whether the add succeeded or not.
Quantity and removal in the mini-cart take a snapshot first, project, and put it back if the server refuses:
The removed row re-enters through the same transition it left by, and the warning says why.
Counting units or lines¶
Magento decides what the bag badge means: checkout/cart_link/use_qty makes summary_count the number of units, off it counts lines. The projection has to agree, or adding two of something moves the badge by the wrong amount and the reload visibly corrects it.
That setting travels in the same global as summaryCountsQty, which is why the projection reads it instead of assuming.
Loading state¶
No component owns a loading flag. Both the button and the badge derive theirs from the bus:
The bag badge grows a thin rotating ring around the icon and fades the number to 45% while anything cart-related is in flight. Since the add is optimistic, the number is already the new one β the ring says "syncing", not "I don't know yet".
The button keeps its label in the box and hides it with visibility, then centres the spinner absolutely on top:
Swapping the label out for a spinner is the obvious implementation and the wrong one: the spinner is shorter and narrower than the text, so the button shrinks the moment it is clicked and everything around it moves. Keeping the label reserves the exact box it already had. Nothing shifts, and no min-width needs guessing.
The spinner is 1em, so it scales with whatever button it lands in.
Motion¶
Removing a row¶
The row slides right and fades while the rows below glide up to close the gap:
position: absolute on the leaving row is the whole trick. Vue's TransitionGroup computes the FLIP offsets for -move from where the remaining elements land; while the leaving row still occupies its space, they land nowhere. Taking it out of flow lets them move up immediately, and the transition animates the difference.
Only transform and opacity are animated β both composited, neither triggering layout. Animating height to collapse the row would do the same thing visually at the cost of a layout pass per frame.
The scroll container needs overflow-x: hidden
The leaving row translates 24 px past the right edge of a list that is overflow-y: auto. CSS resolves the other axis of a scroll container to auto too, so the drawer grows a horizontal scrollbar for the length of every removal. Pinning overflow-x: hidden on the list is the fix.
Changing quantity¶
Three things happen at once, each saying something different:
- The number changes immediately. The controls are never disabled, so clicking
+three times fast works; the requests queue and the last one wins. - The stepper bumps (
scale(1.08), 250 ms) β the acknowledgement that the click registered. - The line price dims and pulses while its request is in flight, and the subtotal flashes
--color-accent-softwhen its value actually changes.
The flash is a composable, so the bag page and checkout get it the same way:
The shimmer is on the price only, not the row. Dimming the whole row to say "one number is updating" reads as "this item is disabled".
Emptying¶
When the last row leaves, the list cross-fades into the empty panel instead of collapsing to it β <Transition mode="out-in"> around the two states.
Destructive actions¶
Remove controls use --color-danger (#a4322b, a muted mineral red that sits inside the OBSIDIAN palette), at 72% opacity, reaching full opacity and scale(1.1) on hover. It is a theme token, not a mini-cart constant, so anything destructive across the storefront uses the same red.
The icon comes from the shared sprite through the Icon component, never inline SVG β the same source hero_icon() uses, so a hydrating component matches its server markup.
Reduced motion¶
Every transition and animation in this document is switched off under prefers-reduced-motion: reduce, including the spinners:
The result is still fully functional: rows disappear, numbers change, states are still legible β they simply arrive instantly. Optimistic UI is why that works, since without the animation there is nothing to wait for.
Key Notes¶
- Motion class names are written as plain CSS selectors in
module.extend.css, not Tailwind utilities. Nothing references them statically, so the tree-shaker would drop them. - That file lives in
view/frontend/web/css/, notview/frontend/web/β anywhere else and the theme does not build it. patchnever persists. The canonical customer-data cache stays Magento's.- Only project what the server can confirm. The mini-cart projects
itemsandsummary_count; it does not recompute the subtotal, because tax, discounts and totals rules are the server's answer to give. - Optimistic does not mean silent. A refused mutation always reverts and says so.
Next Steps¶
- Storefront Events β the convention the loading state is derived from.
- State Management β
patch,snapshotandrestorein context. - Performance β the budget this motion has to live inside.