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()inapps/web/src/hooks/use-motion-preference.tsreturns the effectivefull | reduced | offlevel 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.LumioMotionConfigusesmotion/react: Reduced setsreducedMotion="always", and Off also setsskipAnimations. Mounts exist in the dashboard shell and popout layout; each surface has its scoped config.- The
motion-ok:variant inshared/ui/src/globals.cssgates 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-baseand--motion-duration-sloware 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.tsexposesmotionLevelAt(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
| # | Animation | Where | Full | Reduced | Off | Mech. |
|---|---|---|---|---|---|---|
| G1 | Mobile sidebar panel enter/exit | (app)/shell.tsx | slide 12 px + fade, 220 ms | fade only | instant | MC |
| G2 | Mobile sidebar item stagger | shell.tsx | slide 16 px + fade, staggered | fade only | instant | MC |
| G3 | Hamburger ↔ X icon swap | shell.tsx | rotate ±90° + fade | fade only | instant | MC |
| G4 | Desktop sidebar collapse | shell.tsx (transition-[width]) | width glides 200 ms | jumps | jumps | B |
| G5 | Nav section expand/collapse | shell.tsx (transition-all) | height glides | jumps, opacity fades | instant | B |
| G6 | Nav section chevron | shell.tsx | rotates | jumps | jumps | B |
| G7 | Account switcher chevron | components/account-switcher.tsx | rotates | jumps | jumps | B |
| G8 | Tooltips | shared/ui/src/tooltip-provider.tsx + keyframes in app/globals.css | 2 px rise + fade, 150 ms | fade 150 ms (2 px rise kept — below the movement threshold) | instant | B |
| G9 | Toasts | contexts/toast-context.tsx (animate-fade-in-up) | rise 16 px + fade, 500 ms | fade ≤150 ms | instant | B |
| G10 | AlertDeck slide switch + height | shared/ui/src/alert-deck.tsx | slide + blur + fade, height glides | 120 ms fade | instant | D + B |
| G11 | AlertDeck swipe drag | alert-deck.tsx | follows the finger (direct manipulation) | follows the finger | follows the finger | |
| G12 | AlertDeck button press | alert-deck.tsx (active:scale-[0.97]) | scales | colour only | instant | B |
| G13 | .btn-primary hover lift | shared/ui/src/globals.css (10 uses) | lifts 1 px | colour only | instant | B |
| G14 | Select chevron | shared/ui/src/select.tsx | rotates | jumps | jumps | B |
| G15 | Colour picker swatch hover | shared/ui/src/color-picker.tsx | scales 110 % | jumps | jumps | B |
| G16 | Wizard progress bar | shared/ui/src/wizard-steps.tsx | width glides | jumps (information kept) | jumps | B |
| G17 | Switch thumb | shared/ui/src/switch.tsx | colour fades, thumb jumps | same | instant | B |
| G18 | Colour/opacity-only transitions (dialog close, menus, tabs, theme toggle, badges, inputs, nav items, bell) | various | fade | fade | instant | B |
| G19 | Aurora background (ribbons, floor, accent, particles, grain) | shared/ui/src/aurora-background.tsx, (app)/layout.tsx | infinite drift | still | still | B |
| G20 | Dashboard error page | (app)/error.tsx | breathe/pulse-ring/float loops, fade-up, retry spin | one pass, no loop; retry spin kept | instant; retry spinner pulses | B |
Settings
| # | Animation | Where | Full | Reduced | Off | Mech. |
|---|---|---|---|---|---|---|
| S1 | Settings page controls | dashboard/settings/*, Switch | colour only | colour only | instant | B |
| S2 | Popout-settings toggles | components/popout-settings.tsx | colour fades, thumb jumps | same | instant | B |
| S3 | Popout-settings group chevron | popout-settings.tsx | rotates | jumps | jumps | B |
| S4 | Popout-settings group collapse | popout-settings.tsx | height glides | jumps, opacity fades | instant | B |
| S5 | Popout-settings trigger | popout-settings.tsx | colour | colour | instant | B |
Chat (dashboard page and popout share ChatShell)
| # | Animation | Where | Full | Reduced | Off | Mech. |
|---|---|---|---|---|---|---|
| C1 | "Jump to newest" | dashboard/chat/multichat.tsx | smooth scroll | instant | instant | H |
| C2 | Auto-pin to bottom / entry restore | multichat.tsx | instant (by design) | instant | instant | |
| C3 | Keyword tab strip arrows | components/chat-shell.tsx | smooth scroll | instant | instant | H |
| C4 | Keyword tab drag-reorder | chat-shell.tsx (dnd-kit) | neighbours glide aside | neighbours jump; dragged tab follows the pointer | same as Reduced | B |
| C5 | YouTube broadcast dropdown chevron | chat-shell.tsx | rotates | jumps | jumps | B |
| C6 | Emote library section scroll | dashboard/chat/emote-library.tsx | smooth scroll | instant | instant | H |
| C7 | Spotlight card arrival | chat-spotlight-card.tsx, app/globals.css | rise + glow + pill pop + sparks | fade ≤150 ms | instant | V + T |
| C8 | Watch-streak count-up | chat-spotlight-card.tsx | counts up 900 ms | final number | final number | V |
| C9 | Watch-streak flame | watch-streak-card.tsx, app/globals.css | lives | still (rest pose) | still | V + H |
| C10 | Spotlight action bar reveal | chat-spotlight-card.tsx | opacity fade | fade | instant | B |
| C11 | Alert progress bars (hype train, ad break, poll, prediction, raid) | dashboard/chat/chat-alerts.tsx | width glides | jumps (information kept) | jumps | B |
| C12 | Raid icon pulse | chat-alerts.tsx (animate-pulse) | pulses | pulses (status signal) | pulses | |
| C13 | Reconnecting dot | multichat.tsx (animate-pulse) | pulses | pulses (status signal) | pulses | |
| C14 | Copy-confirm icon | chat-message-row.tsx, chat-event-row.tsx, watch-streak-card.tsx | icon swap | icon swap | icon swap | |
| C15 | Expand chevrons in messages | expandable-text.tsx, emote-text.tsx | rotates | jumps | jumps | B |
| C16 | Platform chips / mod buttons | multichat.tsx | colour | colour | instant | B |
| C17 | Scroll-to-edge bar | components/scroll-to-edge-bar.tsx | rises 8 px + fades | fades | instant | B |
| C18 | Graveyard strip + input ghost | halloween-graveyard.tsx, season-bar-strip.tsx | normal decoration | strip still, no ghost | strip still, no ghost | S + H |
| C19 | Animated emotes / GIFs | message-content.tsx, emote-text.tsx | play | play (viewer content) | play | |
| C20 | Viewer-list panel show / hide (ZAF-1701, ZAF-1766) | components/chatter-list/chatter-list-panel.tsx | docked: 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) | instant | instant | H |
| C21 | Viewer-list rows join / leave | chatter-list-panel.tsx | fade + 4 px rise 150 ms / fade 120 ms (≤ 20 changes) | instant | instant | H |
| C22 | Viewer-list width change (auto growth, keyboard, settings) | chatter-list-panel.tsx | width glides 150 ms; a drag follows the pointer 1:1 | jumps | jumps | V |
Events (dashboard page and popout share EventsShell)
| # | Animation | Where | Full | Reduced | Off | Mech. |
|---|---|---|---|---|---|---|
| E1 | New-event row enter | components/events-shell.tsx, .animate-fade-in in app/globals.css | slides down + grows + fades, 400 ms | fade ≤150 ms | instant | V + T |
| E2 | Scroll to newest | events-shell.tsx | smooth scroll | instant | instant | H |
| E3 | Reconnecting dot | events-shell.tsx (animate-pulse) | pulses | pulses (status) | pulses | |
| E4 | Event progress bars | components/event-row.tsx | width glides | jumps (information kept) | jumps | B |
| E5 | Copy-confirm scale | event-row.tsx | scales | jumps | instant | B |
| E6 | Highlighted events | event-row.tsx | static highlight | static | static |
Music (dashboard page and popout)
| # | Animation | Where | Full | Reduced | Off | Mech. |
|---|---|---|---|---|---|---|
| M1 | Track progress bar | dashboard/music/music-player.tsx, popout/music/music-popout-client.tsx, hooks/use-spotify-player.ts (nextDisplayProgress) | runs, rAF-smoothed | runs (information, not decoration) | runs, steps once per second | H |
| M2 | Section chevrons | music-player.tsx, music-popout-client.tsx | rotates | jumps | jumps | B |
| M3 | Popout loading icon | music-popout-client.tsx (SeasonalSpinner) | pulses | pulses | pulses | |
| M4 | Connection-lost dot | music-popout-client.tsx | pulses | pulses (status) | pulses | |
| M5 | Action buttons hover lift | .btn-primary (G13) | lifts | colour only | instant | B |
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
| # | Animation | Where | Full | Reduced | Off | Mech. |
|---|---|---|---|---|---|---|
| O1 | Onboarding plan cards hover lift | dashboard/onboarding.tsx | lifts 4 px | colour only | instant | B |
| O2 | Channel-stats live dot | channel-stats.tsx (animate-pulse) | pulses | pulses (status) | pulses | |
| O3 | Live Radio icon pulse | stream-history-panel.tsx, history/history-list.tsx, history/reports/[uuid]/report-header.tsx | pulses | pulses (status) | pulses | |
| O4 | Test-event panel chevron | test-event-panel.tsx | rotates | jumps | jumps | B |
| O5 | Subscription plan card hover lift | account/subscription/plan-selector.tsx | lifts 4 px | colour only | instant | B |
| O6 | History report bar chart grow + tooltip slide | history/reports/[uuid]/charts.tsx (Recharts JS tween) | bars grow, tooltip slides | bars at full height, tooltip in place | same as Reduced | H (isAnimationActive) |
| O7 | Idea card chevron nudge | hub/ideas/idea-card.tsx | nudges 2 px | jumps | jumps | B |
| O8 | Toggle thumbs (bot modules, store installed, OBS config) | module-list.tsx, store/installed/page.tsx, connections/obs-config.tsx | thumb glides | thumb jumps | instant | B |
| O9 | Progress bars (developer wizard, extension limits/analytics) | application-wizard.tsx, extensions/limits/page.tsx, extensions/[slug]/analytics/page.tsx | width glides | jumps (information kept) | jumps | B |
| O10 | OBS scene buttons / .btn-primary | obs/obs-dashboard.tsx | colour + lift | colour only | instant | B |
| O11 | Automations canvas | shared/automation/src/canvas/AutomationCanvas.tsx | no animation configured | — | — |
Loading indicators
| # | Animation | Where | Full | Reduced | Off | Mech. |
|---|---|---|---|---|---|---|
| L1 | Inline spinners (animate-spin, ~70 sites) | dashboard, popouts, shared/ui | spins | spins | opacity pulse (still "loading") | B |
| L2 | Skeletons (animate-pulse, ~20 sites) | dashboard pages, user card | pulses | pulses | pulses | |
| L3 | SeasonalSpinner | components/seasons/seasonal-slots.tsx | ghosts bob (season) / regular spinner | ghosts 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).
| # | Animation | Where | Reduced / Off | Mech. |
|---|---|---|---|---|
| SD1 | Loader ghosts | lib/seasons/styles.ts, seasonal-slots.tsx | opacity pulse so loading remains visible | S |
| SD2 | Bat drift | styles.ts, halloween-scenery.tsx | still | S |
| SD3 | Empty-state illustration float | styles.ts, seasonal-slots.tsx | still | S |
| SD4 | Toast bats | styles.ts, halloween-assets.tsx | still | S |
| SD5 | Brand pumpkin blink + tip | halloween-brand.tsx, logo-motion.ts | no bursts | H |
| SD6 | Pumpkin hover wobble | halloween-brand.tsx | brighter only, no wobble | H |
| SD7 | Witch flight from the i-dot | halloween-brand.tsx | no flight | H |
| SD8 | Halo hover | lib/seasons/halloween-brand-css.ts | colour only | S |
| SD9 | Bat wing-flap bursts | halloween-scenery.tsx, scenery-motion.ts | none | H |
| SD10 | Bat fly-by | halloween-scenery.tsx | none | H |
| SD11 | Corner spider crawl/drop | halloween-scenery.tsx, spider-motion.ts | none | H |
| SD12 | Graveyard ghost peek | halloween-graveyard.tsx, peek-coordinator.ts | none | H |
| SD13 | Bar ghost peek/glide | halloween-graveyard.tsx, season-bar-strip.tsx | none | H |
| SD14 | Emote-button ghost glance | halloween-emote-icon.tsx | none | H |