Skip to main content

Motion

The Animations in Lumio preference controls Lumio's dashboard and popout animations. The profile field motion_preference is system (default), reduced, or off. system follows the operating system's prefers-reduced-motion; the effective level is full, reduced, or off. Reduced removes movement, scaling and loops but permits short fades up to 150 ms. Off makes transitions and animations instant. Loading remains visibly in progress. Animated emotes and GIFs are viewer content and continue playing.

Scope and resolution​

apps/web/src/lib/motion/bootstrap.ts runs before paint in the same SeasonHead script as the seasonal resolver. It combines the SSR profile value, the lumio-motion mirror cookie (dashboard only), the OS preference, and an optional stricter ?motion=reduced|off on popout URLs. It writes data-motion="full|reduced|off" to <html>. Popouts use their represented user's profile through popoutSession, never the dashboard cookie; they can read but cannot write the global preference. Unknown URL values are ignored. There is no always-on option that overrides the OS.

Chat, Events and Music carry a separate PopoutSettingsState.motion of inherit, reduced, or off, stored per view, browser, user and account. MotionSurface places a stricter data-motion value on the view root and gives React the matching MotionScope and LumioMotionConfig. The effective value is always the strictest of the OS, global preference and view override. The settings row disables a looser choice, such as Reduced when global motion is Off. The seasonal Animations: Automatic / Off row remains separate: data-season-motion governs decoration only, and global Reduced or Off keeps that decoration still.

Animation helpers​

  • useMotionPreference() in apps/web/src/hooks/use-motion-preference.ts returns the effective full | reduced | off level for JavaScript animations. useMotionAllowed() is true only at Full; useDocumentMotionLevel() reads the global level for settings controls. The server and hydration snapshot is Reduced, so animation waits until the browser resolves the actual level.
  • LumioMotionConfig uses motion/react: Reduced sets reducedMotion="always", and Off also sets skipAnimations. Mounts exist in the dashboard shell and popout layout; each surface has its scoped config.
  • The motion-ok: variant in shared/ui/src/globals.css gates movement and scaling on Full. Put a static rest pose in the unprefixed rule. The CSS baseline keeps colour and opacity transitions under Reduced, removes motion and smooth scrolling under Off, and keeps loaders perceptible.
  • --motion-duration-fast, --motion-duration-base and --motion-duration-slow are 150/200/300 ms at Full, 100/150/150 ms at Reduced, and 0 at Off. Use them for permitted fades and transitions; duration tokens alone do not remove movement. shared/ui/src/motion.ts exposes motionLevelAt(el) for shared components outside the React context.

Every new dashboard or popout animation must use the hook or the CSS variant and duration tokens. Avoid direct prefers-reduced-motion checks in feature code.

Widget and overlay boundary​

Widgets and overlays are stream content and never follow this preference, including their previews, editors and designers inside the dashboard and their /overlay/… and /widget/… embeds. Inline previews use MotionExempt with data-motion="exempt": the CSS gate stops at that boundary and the React hook reports Full beneath it. Iframes have their own document. The (overlay) routes have no motion bootstrap or ?motion= handling. apps/web/__tests__/motion-import-ban.test.ts rejects motion-hook and CSS-helper imports in widget, overlay, editor and designer code, and forbids motion bootstrap code in (overlay) routes.

Animation inventory​

The following inventory is the behaviour implemented for dashboard and popout motion. Mechanisms: B CSS baseline, V motion-ok:, T duration tokens, MC LumioMotionConfig, H React hook, S seasonal resolver, D motionLevelAt(el). Loading indicators and status pulses retain a visible signal at Off. Directly dragged elements still follow the pointer.

Global chrome​

#AnimationWhereFullReducedOffMech.
G1Mobile sidebar panel enter/exit(app)/shell.tsxslide 12 px + fade, 220 msfade onlyinstantMC
G2Mobile sidebar item staggershell.tsxslide 16 px + fade, staggeredfade onlyinstantMC
G3Hamburger ↔ X icon swapshell.tsxrotate ±90° + fadefade onlyinstantMC
G4Desktop sidebar collapseshell.tsx (transition-[width])width glides 200 msjumpsjumpsB
G5Nav section expand/collapseshell.tsx (transition-all)height glidesjumps, opacity fadesinstantB
G6Nav section chevronshell.tsxrotatesjumpsjumpsB
G7Account switcher chevroncomponents/account-switcher.tsxrotatesjumpsjumpsB
G8Tooltipsshared/ui/src/tooltip-provider.tsx + keyframes in app/globals.css2 px rise + fade, 150 msfade 150 ms (2 px rise kept — below the movement threshold)instantB
G9Toastscontexts/toast-context.tsx (animate-fade-in-up)rise 16 px + fade, 500 msfade ≤150 msinstantB
G10AlertDeck slide switch + heightshared/ui/src/alert-deck.tsxslide + blur + fade, height glides120 ms fadeinstantD + B
G11AlertDeck swipe dragalert-deck.tsxfollows the finger (direct manipulation)follows the fingerfollows the finger
G12AlertDeck button pressalert-deck.tsx (active:scale-[0.97])scalescolour onlyinstantB
G13.btn-primary hover liftshared/ui/src/globals.css (10 uses)lifts 1 pxcolour onlyinstantB
G14Select chevronshared/ui/src/select.tsxrotatesjumpsjumpsB
G15Colour picker swatch hovershared/ui/src/color-picker.tsxscales 110 %jumpsjumpsB
G16Wizard progress barshared/ui/src/wizard-steps.tsxwidth glidesjumps (information kept)jumpsB
G17Switch thumbshared/ui/src/switch.tsxcolour fades, thumb jumpssameinstantB
G18Colour/opacity-only transitions (dialog close, menus, tabs, theme toggle, badges, inputs, nav items, bell)variousfadefadeinstantB
G19Aurora background (ribbons, floor, accent, particles, grain)shared/ui/src/aurora-background.tsx, (app)/layout.tsxinfinite driftstillstillB
G20Dashboard error page(app)/error.tsxbreathe/pulse-ring/float loops, fade-up, retry spinone pass, no loop; retry spin keptinstant; retry spinner pulsesB

Settings​

#AnimationWhereFullReducedOffMech.
S1Settings page controlsdashboard/settings/*, Switchcolour onlycolour onlyinstantB
S2Popout-settings togglescomponents/popout-settings.tsxcolour fades, thumb jumpssameinstantB
S3Popout-settings group chevronpopout-settings.tsxrotatesjumpsjumpsB
S4Popout-settings group collapsepopout-settings.tsxheight glidesjumps, opacity fadesinstantB
S5Popout-settings triggerpopout-settings.tsxcolourcolourinstantB

Chat (dashboard page and popout share ChatShell)​

#AnimationWhereFullReducedOffMech.
C1"Jump to newest"dashboard/chat/multichat.tsxsmooth scrollinstantinstantH
C2Auto-pin to bottom / entry restoremultichat.tsxinstant (by design)instantinstant
C3Keyword tab strip arrowscomponents/chat-shell.tsxsmooth scrollinstantinstantH
C4Keyword tab drag-reorderchat-shell.tsx (dnd-kit)neighbours glide asideneighbours jump; dragged tab follows the pointersame as ReducedB
C5YouTube broadcast dropdown chevronchat-shell.tsxrotatesjumpsjumpsB
C6Emote library section scrolldashboard/chat/emote-library.tsxsmooth scrollinstantinstantH
C7Spotlight card arrivalchat-spotlight-card.tsx, app/globals.cssrise + glow + pill pop + sparksfade ≤150 msinstantV + T
C8Watch-streak count-upchat-spotlight-card.tsxcounts up 900 msfinal numberfinal numberV
C9Watch-streak flamewatch-streak-card.tsx, app/globals.csslivesstill (rest pose)stillV + H
C10Spotlight action bar revealchat-spotlight-card.tsxopacity fadefadeinstantB
C11Alert progress bars (hype train, ad break, poll, prediction, raid)dashboard/chat/chat-alerts.tsxwidth glidesjumps (information kept)jumpsB
C12Raid icon pulsechat-alerts.tsx (animate-pulse)pulsespulses (status signal)pulses
C13Reconnecting dotmultichat.tsx (animate-pulse)pulsespulses (status signal)pulses
C14Copy-confirm iconchat-message-row.tsx, chat-event-row.tsx, watch-streak-card.tsxicon swapicon swapicon swap
C15Expand chevrons in messagesexpandable-text.tsx, emote-text.tsxrotatesjumpsjumpsB
C16Platform chips / mod buttonsmultichat.tsxcolourcolourinstantB
C17Scroll-to-edge barcomponents/scroll-to-edge-bar.tsxrises 8 px + fadesfadesinstantB
C18Graveyard strip + input ghosthalloween-graveyard.tsx, season-bar-strip.tsxnormal decorationstrip still, no ghoststrip still, no ghostS + H
C19Animated emotes / GIFsmessage-content.tsx, emote-text.tsxplayplay (viewer content)play
C20Viewer-list panel show / hide (ZAF-1701, ZAF-1766)components/chatter-list/chatter-list-panel.tsxdocked: the panel's slot width runs 0 ↔ W (200 ms in, 150 ms out, ease-out, retargets when toggled mid-flight), so the feed narrows / widens with it while the panel slides in from its edge; overlay: slides 16 px + fades. Only after a user action; the panel stays mounted while hidden (inert)instantinstantH
C21Viewer-list rows join / leavechatter-list-panel.tsxfade + 4 px rise 150 ms / fade 120 ms (≤ 20 changes)instantinstantH
C22Viewer-list width change (auto growth, keyboard, settings)chatter-list-panel.tsxwidth glides 150 ms; a drag follows the pointer 1:1jumpsjumpsV

Events (dashboard page and popout share EventsShell)​

#AnimationWhereFullReducedOffMech.
E1New-event row entercomponents/events-shell.tsx, .animate-fade-in in app/globals.cssslides down + grows + fades, 400 msfade ≤150 msinstantV + T
E2Scroll to newestevents-shell.tsxsmooth scrollinstantinstantH
E3Reconnecting dotevents-shell.tsx (animate-pulse)pulsespulses (status)pulses
E4Event progress barscomponents/event-row.tsxwidth glidesjumps (information kept)jumpsB
E5Copy-confirm scaleevent-row.tsxscalesjumpsinstantB
E6Highlighted eventsevent-row.tsxstatic highlightstaticstatic

Music (dashboard page and popout)​

#AnimationWhereFullReducedOffMech.
M1Track progress bardashboard/music/music-player.tsx, popout/music/music-popout-client.tsx, hooks/use-spotify-player.ts (nextDisplayProgress)runs, rAF-smoothedruns (information, not decoration)runs, steps once per secondH
M2Section chevronsmusic-player.tsx, music-popout-client.tsxrotatesjumpsjumpsB
M3Popout loading iconmusic-popout-client.tsx (SeasonalSpinner)pulsespulsespulses
M4Connection-lost dotmusic-popout-client.tsxpulsespulses (status)pulses
M5Action buttons hover lift.btn-primary (G13)liftscolour onlyinstantB

There is no marquee, visualizer or cover cross-fade in the music surfaces today. When one is built it follows the same rules: Reduced keeps the title standing with "…" and fades the cover in ≤150 ms; Off is instant.

Other dashboard pages​

#AnimationWhereFullReducedOffMech.
O1Onboarding plan cards hover liftdashboard/onboarding.tsxlifts 4 pxcolour onlyinstantB
O2Channel-stats live dotchannel-stats.tsx (animate-pulse)pulsespulses (status)pulses
O3Live Radio icon pulsestream-history-panel.tsx, history/history-list.tsx, history/reports/[uuid]/report-header.tsxpulsespulses (status)pulses
O4Test-event panel chevrontest-event-panel.tsxrotatesjumpsjumpsB
O5Subscription plan card hover liftaccount/subscription/plan-selector.tsxlifts 4 pxcolour onlyinstantB
O6History report bar chart grow + tooltip slidehistory/reports/[uuid]/charts.tsx (Recharts JS tween)bars grow, tooltip slidesbars at full height, tooltip in placesame as ReducedH (isAnimationActive)
O7Idea card chevron nudgehub/ideas/idea-card.tsxnudges 2 pxjumpsjumpsB
O8Toggle thumbs (bot modules, store installed, OBS config)module-list.tsx, store/installed/page.tsx, connections/obs-config.tsxthumb glidesthumb jumpsinstantB
O9Progress bars (developer wizard, extension limits/analytics)application-wizard.tsx, extensions/limits/page.tsx, extensions/[slug]/analytics/page.tsxwidth glidesjumps (information kept)jumpsB
O10OBS scene buttons / .btn-primaryobs/obs-dashboard.tsxcolour + liftcolour onlyinstantB
O11Automations canvasshared/automation/src/canvas/AutomationCanvas.tsxno animation configured——

Loading indicators​

#AnimationWhereFullReducedOffMech.
L1Inline spinners (animate-spin, ~70 sites)dashboard, popouts, shared/uispinsspinsopacity pulse (still "loading")B
L2Skeletons (animate-pulse, ~20 sites)dashboard pages, user cardpulsespulsespulses
L3SeasonalSpinnercomponents/seasons/seasonal-slots.tsxghosts bob (season) / regular spinnerghosts pulse (also under a surface's Reduced override)ghosts pulse; regular spinner pulses (also under a surface's Off override)S + B

Seasonal decoration​

All rows: Full = normal decoration; Reduced and Off = standing still, because a global Reduced/Off sets data-season-motion="off" (S) and every JS-driven piece asks useMotionAllowed() (H, through the shared motion hook).

#AnimationWhereReduced / OffMech.
SD1Loader ghostslib/seasons/styles.ts, seasonal-slots.tsxopacity pulse so loading remains visibleS
SD2Bat driftstyles.ts, halloween-scenery.tsxstillS
SD3Empty-state illustration floatstyles.ts, seasonal-slots.tsxstillS
SD4Toast batsstyles.ts, halloween-assets.tsxstillS
SD5Brand pumpkin blink + tiphalloween-brand.tsx, logo-motion.tsno burstsH
SD6Pumpkin hover wobblehalloween-brand.tsxbrighter only, no wobbleH
SD7Witch flight from the i-dothalloween-brand.tsxno flightH
SD8Halo hoverlib/seasons/halloween-brand-css.tscolour onlyS
SD9Bat wing-flap burstshalloween-scenery.tsx, scenery-motion.tsnoneH
SD10Bat fly-byhalloween-scenery.tsxnoneH
SD11Corner spider crawl/drophalloween-scenery.tsx, spider-motion.tsnoneH
SD12Graveyard ghost peekhalloween-graveyard.tsx, peek-coordinator.tsnoneH
SD13Bar ghost peek/glidehalloween-graveyard.tsx, season-bar-strip.tsxnoneH
SD14Emote-button ghost glancehalloween-emote-icon.tsxnoneH