Motion & Micro-interaction Design

Copy Markdown View Source

Accrue Admin applies restrained, purposeful, token-based motion to interactive surfaces. Every animation serves a functional role (feedback, continuity, or affordance) and honors prefers-reduced-motion. Decorative motion is inadmissible.

Motion Vocabulary

All new motion composes from these atoms only. Raw ms, s, or cubic-bezier() literals in app.css are banned (see Enforcement Guard below).

Duration atoms

TokenValueIntended use
--ax-dur-instant0msreduced-motion collapse target
--ax-dur-1120mspress, hover, micro feedback
--ax-dur-2180msdefault state change / enter
--ax-dur-3240msdrawer / modal enter (largest travel)
--ax-dur-exit140msexit (snappier than enter — asymmetry)

Easing atoms

TokenCurveIntended use
--ax-ease-outcubic-bezier(0.2,0,0,1)enter / default (decelerate in)
--ax-ease-incubic-bezier(0.4,0,1,1)exit (accelerate away)
--ax-ease-inoutcubic-bezier(0.4,0,0.2,1)continuous loops (skeleton shimmer only)
--ax-ease-emphasiscubic-bezier(0.2,0.9,0.3,1.2)the single earned overshoot (scale-in)

Travel and scale atoms

TokenValueUse
--ax-rise-sm4pxsmall enter translate (dropdowns, toasts) — 0px under reduced-motion
--ax-rise-md8pxlarger enter translate (drawer, palette) — 0px under reduced-motion
--ax-press-scale0.97active-press feedback — 1 under reduced-motion

Property bundles (the primary mechanism — route everything through these)

BundleCompositionAnimates
--ax-transition-colors--ax-dur-2 / --ax-ease-out on color, background-color, border-colorcolor/state changes
--ax-transition-transform--ax-dur-2 / --ax-ease-out on transformslide/scale
--ax-transition-shadow--ax-dur-2 / --ax-ease-out on box-shadowelevation
--ax-transition-baseall of the above (5-property)general controls

Why bundles are non-negotiable: the reduced-motion @media block in theme.css overrides each bundle to --ax-dur-instant (0ms). Any motion routed through a bundle honors prefers-reduced-motion for free (MOT-03). Motion written with inline durations bypasses that override and is a contract violation.

The Motion Contract

Every animated surface is catalogued below. The functional justification column is the justification record — no entry means no animation is permitted.

#Surface (file)TriggerAnimated propertyEnter tokenExit tokenReduced-motion fallbackJustification
1detail_drawer (detail_drawer.ex, .ax-detail-drawer)open/close (@open toggle / mount-remove)transform (translateX --ax-rise-md→0) + opacity 0→1transform --ax-dur-3 --ax-ease-out, opacity --ax-dur-3 --ax-ease-outopacity --ax-dur-exit --ax-ease-in (fade only — no travel out)travel→0 (--ax-rise-md=0px), opacity crossfade retained, dur→instant via bundleContinuity — the drawer slides from the edge it is anchored to, so the operator keeps spatial context with the list behind it.
1bdetail_drawer-backdrop (.ax-detail-drawer-backdrop)same as draweropacity 0→1opacity --ax-dur-3 --ax-ease-outopacity --ax-dur-exit --ax-ease-inopacity retained, dur→instantAffordance — dimming signals a modal layer; click-to-dismiss target.
2dropdown_menu (dropdown_menu.ex, .ax-dropdown-panel)<details> open ([open])opacity 0→1 + transform (translateY --ax-rise-sm→0)--ax-transition-transform + opacity --ax-dur-2 --ax-ease-outopacity --ax-dur-exit --ax-ease-intranslate→0, opacity retained, dur→instant via bundleAffordance — the small rise reads as "this panel belongs to the trigger above it."
3More overflow (.ax-tab-more-menu, demonstrated in dev/component_kitchen_live.ex)toggle openidentical to dropdown_menusame as dropdown_menusame as dropdown_menusame as dropdown_menuAffordance — a recessed-tab overflow menu reveals with the same grammar as any dropdown (consistency).
5global_search palette (global_search.ex, .ax-command-palette)Cmd-K open / Esc/backdrop close (is_open)panel opacity 0→1 + transform scale 0.98→1; backdrop opacitytransform --ax-dur-2 --ax-ease-emphasis, opacity --ax-dur-2 --ax-ease-outopacity --ax-dur-exit --ax-ease-in (no scale-out)scale→none (overshoot removed by override), opacity retained, dur→instantFeedback — the subtle scale-in is the one earned --ax-ease-emphasis use; it announces the palette took focus. Scale is composited (perf-safe).
5bpalette backdrop (.ax-command-palette-backdrop)sameopacity 0→1opacity --ax-dur-2 --ax-ease-outopacity --ax-dur-exit --ax-ease-inopacity retained, dur→instantAffordance — modal dimming.
6tabs active indicator (tabs.ex, .ax-tab / .ax-tab-active)active tab change (link nav)color + border-color (underline indicator) via colors bundle--ax-transition-colorssymmetric (colors bundle)color change retained (no travel), dur→instant via bundleFeedback — confirms selection. Crossfade the indicator color; do not slide it. Tabs are link-based (full page nav between LiveViews) so a slide has no continuous element to animate — a color crossfade is the honest, functional choice.
7flash / toasts (flash_group.ex, .ax-flash)mount (server push) / dismissopacity 0→1 + transform (translateY --ax-rise-sm→0)transform --ax-dur-2 --ax-ease-out, opacity --ax-dur-2 --ax-ease-outopacity --ax-dur-exit --ax-ease-intravel→0, opacity retained, dur→instantFeedback — a new notice slides in so it is noticed; snappy fade-out so dismissal feels immediate.
8skeleton to content (data_table.ex, .ax-skeleton)data arrives (skeleton removed, rows mount)content opacity 0→1 (crossfade in); skeleton shimmer already existsopacity --ax-dur-2 --ax-ease-out on mounted contentn/a (skeleton removed by LiveView patch)shimmer already animation:none + static (app.css:7906); content crossfade — opacity retained, dur→instantContinuity — content fades in where the skeleton was, so the table does not pop. Shimmer is the only sanctioned infinite loop (genuine loading; antipattern A4 exception).
9badge / state change (.ax-status-badge, .ax-badge, nav attention badges from Phase 175)status change / first-appearcolor + background + border via colors bundle; first-appear adds transform scale --ax-press-scale→1--ax-transition-colors; first-appear: --ax-transition-transform (--ax-ease-emphasis optional, single pop)symmetric (colors bundle)scale→none (--ax-press-scale=1), color change retained, dur→instantFeedback — a status that just changed (e.g. invoice to paid, dead-letter count appears) draws a glance without a layout shift.

Enter/exit asymmetry (Phase-174 D-16, now encoded):

  • Enter = gentle: --ax-ease-out, longer duration (--ax-dur-2 / --ax-dur-3), may include travel/scale.
  • Exit = snappy: --ax-ease-in + --ax-dur-exit (140ms), fade-only — no travel/scale out. Dismissal should feel immediate; an element leaving does not need to narrate where it goes.

Antipattern List

Source: Emil Kowalski, "Great Animations"https://emilkowal.ski/ui/great-animations (fetched 2026-06-04). This list is the normative "do not" and the basis of the enforcement guard.

IDAntipatternRule for accrue_admin
A1transition: allBanned. Name the exact properties (or use an --ax-transition-* bundle). Animating "all" animates layout props by accident and kills perf.
A2Animating layout-triggering properties (height:auto, width, margin, padding, top/left)Banned for motion. Animate only transform and opacity (composited, 60fps). Reveal/collapse uses opacity + the structural hidden/[open] toggle, never animated height.
A3Raw duration / curve literals (200ms, 0.3s, cubic-bezier(...)) in app.cssBanned. Compose from --ax-dur-* / --ax-ease-* atoms only. Genuine token gaps get flagged, not literal-ized.
A4Decorative / infinite animation (spinners, auto-loops, parallax)Banned except genuine loading. The skeleton shimmer (ax-skeleton-shimmer) and .ax-spinner (active fetch only) are the sole sanctioned loops.
A5Durations outside 150–300msBanned. Enter must use --ax-dur-2 (180ms) or --ax-dur-3 (240ms); exit uses --ax-dur-exit (140ms — the only allowed sub-150 exception, intentional for snappy dismissal). Micro press/hover --ax-dur-1 (120ms) is feedback, not entrance.
A6Ignoring prefers-reduced-motionBanned. All motion routes through the bundles so the theme.css:358 override collapses it. No motion may be authored that bypasses the bundle override.
A7Animating frequently-repeated / keyboard-initiated actionsBanned. Cmd-K to palette uses one subtle scale-in, not a heavy entrance; nav between tabs is a color crossfade, not a slide. Power-user paths stay fast.
A8Decorative motion ("looks nicer")Inadmissible. Every animation in the table above cites continuity / feedback / affordance. The motion.md entry is the justification record; no entry means no animation.

Enforcement Guard

The CI script scripts/ci/verify_package_docs.sh contains motion antipattern guards (Phase 177, MOT-01) that ban transition: all, raw cubic-bezier() literals, raw ms/s duration literals in transition/animation rules (except ax-skeleton-shimmer 1.4s), and layout-thrashing properties in transition lists. These guards are paired with negative tests in package_docs_verifier_test.exs. Any new CSS rule violating these patterns will fail CI.

Reduced-motion

All transitions route through --ax-transition-* bundles or individual --ax-dur-*/--ax-ease-* atoms. The @media (prefers-reduced-motion: reduce) block in theme.css collapses these bundles to --ax-dur-instant (0ms), removing travel and overshoot while retaining opacity crossfades. No JS code should check matchMedia — the token override handles it. The reduced-motion behavior is verified by an automated Playwright check in e2e/reduced-motion.spec.js.