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 chat emotes are viewer content. Stream History report GIF tiles stay still under Reduced and Off until hover or focus.

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.

The Chat and Events default-preset loading frames read the saved surface override through the pre-paint layout hint and carry the same data-motion as their loaded shells. The Music popout starts with its saved override as well. Their skeleton blocks retain an opacity pulse at Reduced and Off.

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 loading perceptible. Its exact .animate-pulse exemption preserves the opacity pulse on Skeleton blocks at Reduced and Off.
  • A translate inside a scroll container counts toward its scrollable overflow. overflow-x-auto alone also makes the y axis auto, so a row that rises into place can flash a vertical scrollbar until it lands. A container that scrolls on one axis only sets the other axis to hidden (overflow-x-auto overflow-y-hidden), and hover nudges stay inside the cell padding.
  • --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 (.lm-toast-enter / .lm-toast-exit)rise 8 px + fade in, 200 ms; automatic and ✕ click dismissal fade out 150 msfade ≤150 ms on either dismissalinstant on either dismissalB
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.tsx; also behind the ID app's sign-in card (apps/id login/card.tsx), which has no Lumio motion setting: there still (standing ribbons, no particles) follows the OS reduced-motion choice and is always on in the compact popout / OBS dock card and app sign-insinfinite 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​

The route tabs of Settings, Security, Channel settings, Connections, Billing and developer teams use the shared RouteTabs component: the active marker glides for 220 ms under motion-ok, and jumps under Reduced or Off (inventory G21). When the row is wider than its space, the chevron buttons a mouse gets scroll it smoothly under System and jump under Reduced or Off; the component reads the level with motionLevelAt() (G21b). Mouse-wheel and drag scrolling follow the pointer directly and have no animation of their own. Tab content fades for 120 ms without moving under System and Reduced, and changes instantly under Off (G22). Centered dialogs fade and scale under System while retaining their blurred backdrop in every mode (G23). Multi-step 2FA and email dialogs slide and resize under System, fade under Reduced, and jump under Off (G24). Code boxes and error shakes stop moving under Reduced (G25). The two-second hold progress (deleting the profile, dissolving a channel, and cancelling either) remains visible under Reduced; under Off the user types the name instead to delete or dissolve, and cancelling is a plain button (G26). Busy buttons keep their label and a visible, still ring when motion is reduced or off (L4).

#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
C7aAnnouncement accentchat-spotlight-card.tsx, app/globals.cssone light streak and megaphone wiggle on live arrivalnonenoneV
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
C10aPin card arrivalchat-pin-card.tsx, app/globals.cssslides and fades; needle sticks oncefade ≤150 ms, no needleinstantV + T
C10bPin card collapse / expandchat-pin-card.tsxheight glidesinstantinstantB
C10cPin card exitchat-pin-card.tsx, app/globals.cssfades outfade ≤150 msinstantB
C10dPin countdownchat-pin-card.tsxadvances as time informationadvancesadvances—
C10eMod / VIP role event arrivalchat-role-event-row.tsx, app/globals.cssrises and fades; grant stamps once, revoke draws a strikefade ≤150 msinstantV + T
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
C23Live 7TV emote-change linechat-event-row.tsx, seventv-event-detail.tsx, app/globals.cssbrief accent tint; an added image pops in, a removed image dims and strikes through, a renamed name fades infades only, at most 150 msinstantV + T
C26Emote library bottom sheet up to 480 px (ZAF-2600)emote-library.tsx via @lumio/ui BottomSheetslides up from the input bar 300 ms, backdrop fades; close slides down 200 ms; grip drag follows the pointerfades onlyinstantD

The 7TV line only animates when the live WebSocket arrival claims its motion within one second. History, reconnect gap-fill, tab switches and remounts show the rest state. The Chat override can reduce or turn off this motion.

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 content loadingmusic-popout-client.tsx (MusicPopoutSkeleton)skeletons pulseskeletons pulseskeletons pulseB
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
O2Dashboard home block entrance (.lm-home-enter)components/dashboard-home/, app/globals.css8 px rise + fade, 220 ms, 40 ms staggerfade ≤150 msinstantV + T
O3Dashboard home live switch and sampled numbershome-strip.tsx, home-main.tsx, AnimatedNumber280 ms crossfade; numbers count to their values in 900 msfade; final numberinstantV + H + T
O3aDashboard home weekly-review bars (.lm-week-bar) and "Up next" countdownhome-week.tsx, app/globals.cssbars grow from the baseline in 700 ms, 30 ms stagger; countdown digits change in place (no digit animation)fade ≤150 msinstantV + T
O4Dashboard activity and test rowshome-activity.tsx, test-event-catalog.tsxenter from above in 240 msfade ≤150 msinstantV + T
O4aTest Event side sheet and expanded formDialogContent variant="sheet", .lm-te-expandright slide (phone: bottom) 220 ms; form rise 180 msfades ≤150 msinstantV + T
O5Subscription plan card hover liftaccount/subscription/plan-selector.tsxlifts 4 pxcolour onlyinstantB
O6History report charts and crosshaircomponents/history/report/charts.tsxno entry tween; tooltip appears at the crosshair with no position glide, crosshair, dots and box only in the hovered chart (no sync)no entry tween; no tooltip glideno entry tween; no tooltip glideH
O6aHistory list row entrance, live dot and progressdashboard/history/, .lm-row-enter, .lm-live-dot, .lm-progressshort row stagger; status and loading pulseno row movement; status/loading pulseinstant rows; status/loading pulseV + T
O6bHistory report GIF tilescomponents/history/report/top-lists.tsxanimated GIFstill placeholder until hover/focusstill placeholder until hover/focusH
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.
L1Action and hand-off spinners (animate-spin, about 50 sites)buttons, refresh icons, OAuth and redirect waits in dashboard, popouts and shared/uispinsspinsopacity pulse (still loading)B
L2Content skeletons (Skeleton, SkeletonText, SkeletonRegion from @lumio/ui; blocks carry animate-pulse)dashboard, popouts, feeds, cards, user details, history list/report/share loading layoutspulsepulsepulseB (animate-pulse exemption)
L3SeasonalSpinner, for a standalone wait without a content shapecomponents/seasons/seasonal-slots.tsxHalloween ghosts bob or St. Nicholas stars hop (season) / regular spinnerseasonal icons pulse (also under a surface's Reduced override)seasonal icons 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.
SD1Seasonal loader iconslib/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
SD15St. Nicholas star-loader hopnicholas-css.ts, seasonal-slots.tsxopacity pulse remainsS
SD16Snowfall, star twinkle and compact garlandnicholas-css.ts, nicholas-scenery.tsxstillS
SD17Boot, shooting star and robin flightnicholas-scenery.tsx, nicholas-scenery-motion.tsno flight; settle to restH
SD18Snowy-roof Nicholas peeknicholas-edge-strip.tsx, peek-coordinator.tsnoneH
SD19Boot-L brand and Nicholas emote iconnicholas-brand.tsx, nicholas-emote-icon.tsxstill, with permitted colour/opacity responseH
SD20Snowy-roof window lights (opacity only)nicholas-edge-strip.tsx, window-lights.tsall windows lit; scheduler stoppedH