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.
| Intent | Communicates | Where it lives |
|---|---|---|
| Feedback | a direct interaction was received (press, hover, focus, selection) | component-level transitions — button, form fields; not this module |
| State Transition | a control changed state | .bo-motion-collapse; also tabs/switch/checkbox in their own CSS |
| Entrance / Exit | an element joined or left the interface | .bo-motion-fade-in, -fade-out, -scale-in, the two -slide-in-* classes |
| Spatial Transition | where an element exists relative to the rest of the screen | drawer/offcanvas, dialog — their own directional transitions, not this module |
| Progress | a process is still running | .bo-motion-spin; also skeleton, native <progress> |
| Confirmation | an action completed | toast/alert success states — text + icon, no dedicated motion class here |
| Attention | something changed and is worth noticing, without stealing focus | .bo-motion-pulse-once — the HTMX settle flash, generalized |
| Delight | personality, when the context calls for it | not 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
| Class | Intent | Does |
|---|---|---|
| .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.
<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.
<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.
<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.
Entrance · toast —
slide-in-block-start. It arrives from the edge it is
anchored to, so the motion says where it came from.
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.
| Item | Unit price |
|---|---|
| MRN-2210 | 640.00 |
| MRN-2255 | 1,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.
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.