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()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 loading perceptible. Its exact.animate-pulseexemption preserves the opacity pulse onSkeletonblocks at Reduced and Off. - A translate inside a scroll container counts toward its scrollable overflow.
overflow-x-autoalone also makes the y axisauto, 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 tohidden(overflow-x-auto overflow-y-hidden), and hover nudges stay inside the cell padding. --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 (.lm-toast-enter / .lm-toast-exit) | rise 8 px + fade in, 200 ms; automatic and ✕ click dismissal fade out 150 ms | fade ≤150 ms on either dismissal | instant on either dismissal | 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; 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-ins | 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
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).
| # | 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 |
| C7a | Announcement accent | chat-spotlight-card.tsx, app/globals.css | one light streak and megaphone wiggle on live arrival | none | none | V |
| 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 |
| C10a | Pin card arrival | chat-pin-card.tsx, app/globals.css | slides and fades; needle sticks once | fade ≤150 ms, no needle | instant | V + T |
| C10b | Pin card collapse / expand | chat-pin-card.tsx | height glides | instant | instant | B |
| C10c | Pin card exit | chat-pin-card.tsx, app/globals.css | fades out | fade ≤150 ms | instant | B |
| C10d | Pin countdown | chat-pin-card.tsx | advances as time information | advances | advances | — |
| C10e | Mod / VIP role event arrival | chat-role-event-row.tsx, app/globals.css | rises and fades; grant stamps once, revoke draws a strike | fade ≤150 ms | instant | V + T |
| 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 |
| C23 | Live 7TV emote-change line | chat-event-row.tsx, seventv-event-detail.tsx, app/globals.css | brief accent tint; an added image pops in, a removed image dims and strikes through, a renamed name fades in | fades only, at most 150 ms | instant | V + T |
| C26 | Emote library bottom sheet up to 480 px (ZAF-2600) | emote-library.tsx via @lumio/ui BottomSheet | slides up from the input bar 300 ms, backdrop fades; close slides down 200 ms; grip drag follows the pointer | fades only | instant | D |
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)
| # | 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 content loading | music-popout-client.tsx (MusicPopoutSkeleton) | skeletons pulse | skeletons pulse | skeletons pulse | B |
| 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 | Dashboard home block entrance (.lm-home-enter) | components/dashboard-home/, app/globals.css | 8 px rise + fade, 220 ms, 40 ms stagger | fade ≤150 ms | instant | V + T |
| O3 | Dashboard home live switch and sampled numbers | home-strip.tsx, home-main.tsx, AnimatedNumber | 280 ms crossfade; numbers count to their values in 900 ms | fade; final number | instant | V + H + T |
| O3a | Dashboard home weekly-review bars (.lm-week-bar) and "Up next" countdown | home-week.tsx, app/globals.css | bars grow from the baseline in 700 ms, 30 ms stagger; countdown digits change in place (no digit animation) | fade ≤150 ms | instant | V + T |
| O4 | Dashboard activity and test rows | home-activity.tsx, test-event-catalog.tsx | enter from above in 240 ms | fade ≤150 ms | instant | V + T |
| O4a | Test Event side sheet and expanded form | DialogContent variant="sheet", .lm-te-expand | right slide (phone: bottom) 220 ms; form rise 180 ms | fades ≤150 ms | instant | V + T |
| O5 | Subscription plan card hover lift | account/subscription/plan-selector.tsx | lifts 4 px | colour only | instant | B |
| O6 | History report charts and crosshair | components/history/report/charts.tsx | no 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 glide | no entry tween; no tooltip glide | H |
| O6a | History list row entrance, live dot and progress | dashboard/history/, .lm-row-enter, .lm-live-dot, .lm-progress | short row stagger; status and loading pulse | no row movement; status/loading pulse | instant rows; status/loading pulse | V + T |
| O6b | History report GIF tiles | components/history/report/top-lists.tsx | animated GIF | still placeholder until hover/focus | still placeholder until hover/focus | H |
| 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 | Action and hand-off spinners (animate-spin, about 50 sites) | buttons, refresh icons, OAuth and redirect waits in dashboard, popouts and shared/ui | spins | spins | opacity pulse (still loading) | B |
| L2 | Content skeletons (Skeleton, SkeletonText, SkeletonRegion from @lumio/ui; blocks carry animate-pulse) | dashboard, popouts, feeds, cards, user details, history list/report/share loading layouts | pulse | pulse | pulse | B (animate-pulse exemption) |
| L3 | SeasonalSpinner, for a standalone wait without a content shape | components/seasons/seasonal-slots.tsx | Halloween ghosts bob or St. Nicholas stars hop (season) / regular spinner | seasonal 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).
| # | Animation | Where | Reduced / Off | Mech. |
|---|---|---|---|---|
| SD1 | Seasonal loader icons | 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 |
| SD15 | St. Nicholas star-loader hop | nicholas-css.ts, seasonal-slots.tsx | opacity pulse remains | S |
| SD16 | Snowfall, star twinkle and compact garland | nicholas-css.ts, nicholas-scenery.tsx | still | S |
| SD17 | Boot, shooting star and robin flight | nicholas-scenery.tsx, nicholas-scenery-motion.ts | no flight; settle to rest | H |
| SD18 | Snowy-roof Nicholas peek | nicholas-edge-strip.tsx, peek-coordinator.ts | none | H |
| SD19 | Boot-L brand and Nicholas emote icon | nicholas-brand.tsx, nicholas-emote-icon.tsx | still, with permitted colour/opacity response | H |
| SD20 | Snowy-roof window lights (opacity only) | nicholas-edge-strip.tsx, window-lights.ts | all windows lit; scheduler stopped | H |