busy-office-ui
Menu

Motion

Opt-in, functional animations — not a decorative Animate.css clone. Import separately: @busy-office/ui/css/motion. Never in the default bundle. Every duration comes from the --bo-motion-duration-* tokens, already zeroed under prefers-reduced-motion, so every class here is reduced-motion-safe with no extra markup. Not for structural layout — reach for it once a state is already changing. These classes decorate a transition that's happening anyway (a row appearing, a panel opening); they never decide layout or timing on their own. If nothing is changing yet, motion has nothing to attach to.

Motion intent

Eight intents cover what a transition on this page communicates. Naming the intent first — "this is an Entrance, not Attention" — is what keeps a reach for motion deliberate instead of decorative; not every intent has a shipped utility class below, because some are already served by a component's own behavior, not this module.

IntentCommunicatesWhere it lives
Feedbacka direct interaction was received (press, hover, focus, selection)component-level transitions — button, form fields; not this module
State Transitiona control changed state.bo-motion-collapse; also tabs/switch/checkbox in their own CSS
Entrance / Exitan element joined or left the interface.bo-motion-fade-in, -fade-out, -scale-in, the two -slide-in-* classes
Spatial Transitionwhere an element exists relative to the rest of the screendrawer/offcanvas, dialog — their own directional transitions, not this module
Progressa process is still running.bo-motion-spin; also skeleton, native <progress>
Confirmationan action completedtoast/alert success states — text + icon, no dedicated motion class here
Attentionsomething changed and is worth noticing, without stealing focus.bo-motion-pulse-once — the HTMX settle flash, generalized
Delightpersonality, when the context calls for itnot shipped — this framework targets transactional ERP screens first, where delight is rarely the job; build it in your own app when the context genuinely calls for it

Quick reference generated

ClassIntentDoes
.bo-motion-collapse State Transition expand/collapse via grid-template-rows; toggle data-state="open"
.bo-motion-fade-in Entrance / Exit opacity 0 → 1 on mount
.bo-motion-fade-out Entrance / Exit opacity 1 → 0, then gone
.bo-motion-pulse-once Attention a single background flash — "this row just changed"
.bo-motion-scale-in Entrance / Exit fade + a subtle 0.96 → 1 scale — menu/popover entrance
.bo-motion-slide-in-block-start Entrance / Exit fade + slide from the top — toast entrance
.bo-motion-slide-in-inline-start Entrance / Exit fade + slide from the start edge
.bo-motion-spin Progress continuous rotation for loading indicators

Entrance

Play once on mount. Click Replay to see them again.

fade-in
scale-in
slide-in-inline-start
slide-in-block-start
<div class="bo-motion-fade-in">fade-in</div>
<div class="bo-motion-scale-in">scale-in</div>
<div class="bo-motion-slide-in-inline-start">slide-in-inline-start</div>
<div class="bo-motion-slide-in-block-start">slide-in-block-start</div>

--collapse — expand/collapse

Toggle data-state="open"/"closed" — the same convention dialog/offcanvas already use. Pure CSS: no JS height measurement.

Collapsible content — height animates via grid-template-rows, no JS measurement.
<button class="bo-btn bo-btn--secondary" id="collapse-toggle" aria-expanded="false" aria-controls="collapse-demo">Toggle</button>
<div class="bo-motion-collapse" id="collapse-demo" data-state="closed">
  <div>Collapsible content — height animates via grid-template-rows, no JS measurement.</div>
</div>

Pulse-once — "this just changed"

Generalizes the HTMX settle flash (Using with HTMX) for any element, not just HTMX swaps.

Row total: $12,400.00
<button class="bo-btn bo-btn--secondary" id="pulse-trigger">Simulate update</button>
<div class="bo-motion-pulse-once" id="pulse-demo">Row total: $12,400.00</div>

Spin — loading indicator

Continuous, so it isn't token-duration-driven — guarded by an explicit prefers-reduced-motion override instead (stops rotating; aria-live on the label is the programmatic channel either way).

Loading…

<span class="bo-motion-spin" aria-hidden="true">⟳</span> <span aria-live="polite">Loading…</span>

In context — the same classes on real screens

The demos above animate a plain box, which shows what each class does but not when to reach for it. These are the same classes doing the job they exist for. Pick by intent — Entrance for something arriving, Attention for something that changed under the user, Progress for something still running.

Purchase order PO-88213 approved

Entrance · toast — slide-in-block-start. It arrives from the edge it is anchored to, so the motion says where it came from.

Duplicate · Export · Cancel line

Entrance · menu — scale-in. A subtle 0.96 → 1 reads as "this grew out of the thing you clicked", which a plain fade does not.

A line the server just repriced
ItemUnit price
MRN-2210640.00
MRN-22551,284.00

Attention · a value changed under you — pulse-once on the row the server repriced. This is the one intent that exists because the user did not cause the change, so nothing else on screen points at it.

Progress · saving — spin inside the button that is busy, paired with aria-busy. Keeping it in the button is what ties the wait to the thing being waited on.

Line 30 — Anode kit

Exit · removal — fade-out, the one class on this page that had no demo until now. It buys the half-second a user needs to see WHICH row left; remove the node when the animation ends, not before.

↑↓ move Enter open Esc close