PVR Tech Studio

Motion & Animation

Luminaux's centralized motion system — animation tokens, reusable wrappers and free-core FX, configurable page transitions, and a branded loading experience.

14 min read
Updated July 15, 2026

Luminaux's motion system: a single source of truth for durations, easings, springs and variants (src/lib/motion.ts), a set of reusable wrappers and free-core "FX" components (src/components/motion/), configurable page transitions, and a branded loading experience (src/components/loading/) — all built on motion/react only, with reduced-motion respected globally and non-negotiably.

Overview

Motion in Luminaux is deliberately centralized so the whole app animates with one consistent vocabulary — the same way styles/index.css owns color. Every duration, easing curve, spring and reusable variant lives in src/lib/motion.ts; components pull from there instead of hardcoding magic numbers. On top of the tokens sit two layers:

  • Wrappers — thin, ergonomic components (PageTransition, Stagger, FadeIn, Collapse, AnimatedNumber, SheetModal) that apply the tokens for common cases.
  • FX components — free-core reimplementations of effects that would otherwise require the paid Motion+ tier (Typewriter, ScrambleText, TiltCard, SwipeRow, CursorGlow, Parallax, HeroCanvas, and more).

The canonical live reference is /ui/motion (src/pages/ui/MotionShowcasePage.tsx) — a UI-kit page that demos every reusable motion export in the standard Panel-in-Stagger idiom, opened by a live HeroCanvas variant="lines" banner layered with RotatingWord + AnimatedNumber. Its sections: entrance/scroll (Reveal, Parallax, Stagger) · animated text (Typewriter, RotatingWord, ScrambleText, ScrollRevealLines) · interaction (TiltCard, SwipeRow, ImageRevealSlider) · numbers (AnimatedNumber variants) · overlays (WarpOverlay, SheetModal) · hero backgrounds (all five HeroCanvas variants) · decorative (ParticleField, DotGrid, WaveDivider) · a shell note. Several FX are also woven into sibling /ui/* pages: ImageRevealSlider on Lightbox (B&W→color over public/photos/), Typewriter / ScrambleText / RotatingWord / ScrollRevealLines on Typography, WarpOverlay on Modals, AnimatedNumber on Badges' count bubbles — and StatCard (src/components/ui/StatCard.tsx) wraps its surface in TiltCard for the pointer tilt.

The golden rules

These are authoritative and apply to every line of animation code you add:

  1. Import only from motion/react. The npm package is motion. Never import from framer-motion.
  2. No Motion+. We do not have the paid Motion+ tier. Never add motion-plus / motion-plus-react, use premium components (e.g. their <AnimateNumber>), or the AI Kit (npx motion-ai). Everything here is built from free core APIs — build equivalents the same way.
  3. Reduced motion is global and non-negotiable. MotionProvider sets reducedMotion="user"; never override it. Any imperative animate() or scroll-linked value must also branch on useReducedMotion() (those bypass MotionConfig).
  4. Tokens are the single source of truth. Pull durations/easings/springs/variants from src/lib/motion.ts — no inline magic numbers.
  5. Memoize dynamic motion(as). Creating a motion component inline in render mints a new type each render and remounts (re-animates) the subtree. Use motion.create(as) wrapped in useMemo([as]) (as Stagger / StaggerItem do).

Architecture & files

The motion system is split across three folders — the token file, the wrapper/FX components, and the loading components — plus the canonical showcase page.

Tokens

FileResponsibility
src/lib/motion.tsTokens: durations, easings, springs, variants, PAGE_TRANSITION_KINDS / PAGE_TRANSITIONS, PAGE_LOADER_KINDS, PARALLAX_DISTANCE.

Wrappers & FX (src/components/motion/)

FileResponsibility
index.tsBarrel — re-exports every wrapper + FX component.
MotionProvider.tsxGlobal MotionConfig (reducedMotion="user").
PageTransition.tsxRoute-change transition (driven by config.pageTransition).
Stagger.tsx / StaggerItem.tsxOrchestrated child entrance.
FadeIn.tsxOne-off fade + rise.
Collapse.tsxHeight auto0.
AnimatedNumber.tsxCount-up (free AnimateNumber equivalent).
SheetModal.tsxResponsive dialog / drag-to-dismiss bottom sheet.
Typewriter.tsx / ScrambleText.tsxText FX.
TiltCard.tsx / SwipeRow.tsxGesture FX.
CursorGlow.tsx / WarpOverlay.tsxAmbient / overlay FX.
ImageRevealSlider.tsxBefore/after comparison.
ScrollRevealLines.tsx / Reveal.tsxScroll reveals.
Parallax.tsx / RotatingWord.tsxScroll drift / headline cycle.
ParticleField.tsx / DotGrid.tsxDecorative backgrounds.
WaveDivider.tsx / HeroCanvas.tsxSection decor / hero background.

Loading (src/components/loading/)

FileResponsibility
AppSplash.tsxFull-screen branded splash (initial load + replay).
PageLoader.tsxSuspense fallback for lazy pages.
SplashMark.tsxShared brand lockup used by both.

Showcase

FileResponsibility
src/pages/ui/MotionShowcasePage.tsx/ui/motion — the canonical demo of every export above.

Where they mount:

  • MotionProvider wraps the app near the root so reducedMotion="user" and the default spring transition apply everywhere.
  • PageTransition wraps the routed subtree inside an AnimatePresence (keyed per route) in the layout, so navigating fades/wipes/slides the page per config.pageTransition.
  • AppSplash is mounted in main.tsx next to <App/>, inside the providers so it can useLayout().
  • PageLoader is the Suspense fallback for React.lazy pages (declared in App.tsx).

The motion config flags (pageTransition, pageLoader, routeLoader, splashFrequency) live on LayoutContext alongside the rest of the shell config — see Layout System and Customizer & Settings.

Usage

Import wrappers and FX from the barrel; import tokens from @/lib/motion:

import {FadeIn, Stagger, StaggerItem, AnimatedNumber, Reveal} from '@/components/motion'
import {DURATION, EASE_OUT, spring, fadeInUp} from '@/lib/motion'

Prefer the wrappers over raw motion.* for common cases (entrances, staggering, collapse, count-up, modals). Drop to raw motion.* (still from motion/react) only when a wrapper doesn't fit — and then still pull timing from the tokens.

import {motion} from 'motion/react'
import {DURATION, EASE_OUT} from '@/lib/motion'
 
<motion.div
    initial={{opacity: 0, y: 12}}
    animate={{opacity: 1, y: 0}}
    transition={{duration: DURATION.base, ease: EASE_OUT}}
/>

Motion tokens

All from src/lib/motion.ts.

Easing & durations

TokenValuePurpose
EASE_OUT[0.16, 1, 0.3, 1]Expressive "ease out" curve used for most entrances
DURATION.fast0.15Exits, small feedback
DURATION.base0.25Default entrances
DURATION.slow0.4Clip-path/curtain reveals, scroll reveals

Springs

TokenConfigPurpose
springstiffness: 300, damping: 30Default (set globally via MotionConfig); layout + gestures
springSnappystiffness: 500, damping: 32Small interactive feedback (taps, toggles)
springGentlestiffness: 200, damping: 22Softer shared-element morphs (apps waffle, toast stack)

Key variants (all are Variants with hidden / visible (+ often exit) states)

VariantWhat it does
fadeOpacity-only fade in/out
fadeInUpFade + rise (y: 12 → 0)
scaleInFade + scale (0.96 → 1), spring settle
slideInLeft / slideInRightOff-canvas drawer / panel (mobile sidebar, customizer)
popUpPop from an anchored corner (chat widget) — pair with a transform-origin
staggerContainerOrchestrates child entrance (staggerChildren: 0.06, delayChildren: 0.04)
staggerItemChild of staggerContainer (fade + rise, spring)
collapseExpand/collapse (height: auto ↔ 0 + opacity)
pageVariantsRoute-level fade + subtle vertical slide (used by the slide page transition)
revealInViewScroll-reveal (fade + rise, DURATION.slow) — used with whileInView
headerStagger / headerStaggerItemHeader top-bar drop-in cascade
hoverLiftHover micro-lift (y: -4, springSnappy) — pair with whileHover

Other tokens

TokenType / valuePurpose
REVEAL_MARGIN'-80px'viewport.margin for scroll-reveal triggers
PARALLAX_DISTANCE{heading:12, card:16, section:26, band:44, glow:54, hero:60}Named parallax travel (px) for <Parallax>
PAGE_TRANSITION_KINDS['none','fade','slide','clipWipe','iris','doors','blinds']Selectable route transitions
PAGE_TRANSITIONSRecord<PageTransitionKind, Variants>The variant set per kind
PAGE_LOADER_KINDS['branded','minimal','playful']Splash / route-loader styles

Motion wrappers & FX catalog

Every component below is exported from src/components/motion/index.ts. Props marked ? are optional; defaults are shown where the component sets one.

ComponentPurposeKey props
MotionProviderGlobal MotionConfig — sets reducedMotion="user" + default spring transition. Never disable.children
PageTransitionWraps route content; the active effect comes from config.pageTransition. Forces none under reduced motion.children, className?
StaggerContainer that staggers the entrance of its StaggerItem children.as? (default motion.div), + motion.div props
StaggerItemChild of Stagger; inherits the parent's orchestrated timing.as?, + motion.div props
FadeInOne-off entrance (fade + rise) for standalone elements not in a Stagger.delay? = 0, + motion.div props
CollapseAnimated expand/collapse (height: auto ↔ 0) for submenus/accordions.open, children
AnimatedNumberCounts up to value when scrolled into view; free equivalent of Motion+'s AnimateNumber.value, decimals? = 0, prefix? = '', suffix? = '', duration? = 1, format?, animateOnChange? = false, trend?: 'up'|'down', className?
SheetModalResponsive modal — centered dialog on desktop, drag-to-dismiss bottom sheet on mobile.open, onClose, children, labelledBy?, className?
TypewriterCycles through words, typing then deleting each; static first word under reduced motion.words, typingSpeed? = 70, pause? = 1400, className?
ScrambleTextDecodes text from random glyphs to the final string.text, duration? = 900, trigger?: 'mount'|'hover' = 'mount', className?
TiltCard3D pointer-tilt card (spring-smoothed rotateX/rotateY); no tilt under reduced motion. Used by StatCard (with max={6}).children, max? = 8, className?
SwipeRowSwipe-left-to-act list row; releasing past the threshold fires onSwipe.children, onSwipe?, actionIcon? = Check, actionLabel?, className?
CursorGlowAmbient radial glow that spring-trails the cursor; renders nothing under reduced motion.size? = 420, className?
WarpOverlayFull-screen overlay revealing content with a clip-path warp (center → out); backdrop + Esc close.open, onClose, children, className?
ImageRevealSliderBefore/after image comparison with a draggable, keyboard-accessible handle.before, after, beforeAlt? = '', afterAlt? = '', className?
ScrollRevealLinesReveals text lines one-by-one as they scroll into view (per-line delay).lines, lineClassName?, className?
RevealScroll-reveal wrapper — fades + rises children into view once (uses revealInView + REVEAL_MARGIN).+ motion.div props (no custom props)
ParallaxScroll-parallax — drifts children on Y through the viewport; disabled under reduced motion.children, distance? = PARALLAX_DISTANCE.band, className?
RotatingWordHeadline word cycling through a list (spring up / slide out); static first word under reduced motion.words, interval? = 2400, className?
ParticleFieldAmbient floating-particle background of soft token-colored dots; static under reduced motion.count? = 28, dotClassName? = 'bg-primary', className?
DotGridDecorative token-colored dot-grid texture (radial gradient of --border dots).size? = 24, masked? = false, className?
WaveDividerFull-width decorative wave divider — two layered, slowly-drifting SVG waves. Paints in currentColor.flip?, heightClassName? = 'h-12 sm:h-20', animated? = true, className?
HeroCanvasReusable full-bleed hero background layer with a selectable ambient effect.variant?: 'glow'|'particles'|'dots'|'shader'|'lines' = 'glow', tones?: HeroCanvasTone[] = ['primary','info'], particleCount? = 54, lineCount? = 28, lineGap? = 1, lines?: Partial<LinesConfig>, parallax?: boolean|number, className?

Loading components

From src/components/loading/ — driven by the config flags in the next section.

ComponentPurposeNotes
AppSplashFull-screen branded splash on initial load. Simulated progress creeps to 90%, snaps to 100% after a minimum display time, then fades out.Style follows config.pageLoader; MIN_MS = 1100 floor so it never flashes; splashFrequency === 'once' gates via sessionStorage('app-splash-shown'). Replays on the window event app:replay-splash. Reduced motion → static mark + quick fade.
PageLoaderSuspense fallback for lazy in-app pages.Style follows config.pageLoader; when config.routeLoader is off it reserves height without a visible loader.
SplashMarkShared brand lockup (icon + brand.nameLead / brand.nameAccent) used by both.tagline? = false, className?

The three loader styles (branded / minimal / playful) render distinct visuals in both AppSplash and PageLoader: branded = brand mark + progress bar/shimmer, minimal = a slim indeterminate bar / spinner, playful = three bouncing dots.

Configuration & customization

The four motion config flags

Defined on LayoutContext (src/context/LayoutContext.tsx), persisted to localStorage, and editable from both the Layout Customizer panel and the Layout Settings page:

FlagTypeDefaultEffect
pageTransitionPageTransitionKind'none'Route-change effect (PageTransition)
pageLoaderPageLoaderKind'branded'Splash + route-loader visual style
routeLoaderbooleantrueShow the loader while lazy pages load
splashFrequency'once' | 'always''once'once = one splash per browser session; always = every load

The pickers reuse customizer: i18n keys (src/locales/en/customizer.json):

pageTransition, pageTransitionNone/Fade/Slide/ClipWipe/Iris/Doors/Blinds
pageLoader, pageLoaderBranded/Minimal/Playful
routeLoader, routeLoaderDesc
loaderFrequency, loaderFreqAlways/loaderFreqOnce

How config.pageTransition drives PageTransition

PageTransition reads config.pageTransition via useLayout(), looks up the variant set in PAGE_TRANSITIONS[kind], and applies it as initial="hidden" animate="visible" exit="exit". Under reduced motion it forces PAGE_TRANSITIONS.none (clip-path isn't covered by MotionConfig's reduced-motion handling). The blinds kind additionally renders a BlindsOverlay (8 background-colored slats that retract top-to-bottom) on top of a plain fade.

Add a page-transition kind

  1. Add the kind to PAGE_TRANSITION_KINDS in src/lib/motion.ts.
  2. Add its Variants (clip-path / transform only — no Motion+) and register it in the PAGE_TRANSITIONS map.
  3. Add a pageTransition<Cap> i18n key to customizer.json (e.g. pageTransitionZoom).

The Customizer + Layout Settings pickers map over PAGE_TRANSITION_KINDS automatically, so no picker edits are needed.

Add a page-loader style

  1. Add the style to PAGE_LOADER_KINDS in src/lib/motion.ts.
  2. Add a branch for it in both AppSplash.tsx and PageLoader.tsx.
  3. Add a pageLoader<Cap> i18n key.

Replay the splash on demand

Dispatch the window event — used by the Page Loader preview to show the current style full-screen without a reload:

window.dispatchEvent(new Event('app:replay-splash'))

Examples

Staggered card grid

import {Stagger, StaggerItem} from '@/components/motion'
 
<Stagger className="grid gap-4 sm:grid-cols-3">
    {stats.map((s) => (
        <StaggerItem key={s.id}>
            <StatCard {...s} />
        </StaggerItem>
    ))}
</Stagger>

One-off entrance with a delay

import {FadeIn} from '@/components/motion'
 
<FadeIn delay={0.1}>
    <PageHeader title="Reports"/>
</FadeIn>

Count-up KPI (re-animates when the value changes, with a trend arrow)

import {AnimatedNumber} from '@/components/motion'
 
<AnimatedNumber value={revenue} prefix="$" decimals={2} trend="up" animateOnChange/>

Collapse / accordion body

import {Collapse} from '@/components/motion'
 
<Collapse open={isOpen}>
    <div className="p-4">{body}</div>
</Collapse>

Scroll-reveal a section

import {Reveal} from '@/components/motion'
 
<Reveal>
    <section className="…">…</section>
</Reveal>

Parallax drift with a named distance

import {Parallax} from '@/components/motion'
import {PARALLAX_DISTANCE} from '@/lib/motion'
 
<Parallax distance={PARALLAX_DISTANCE.hero}>
    <img src={heroSrc} alt="" />
</Parallax>

Responsive dialog / bottom sheet

import {SheetModal} from '@/components/motion'
 
<SheetModal open={open} onClose={close} labelledBy="dlg-title">
    <h2 id="dlg-title">Confirm</h2>

</SheetModal>

Text FX

import {Typewriter, ScrambleText, RotatingWord} from '@/components/motion'
 
<Typewriter words={['Fast', 'Themeable', 'Accessible']}/>
<ScrambleText text="demo content" trigger="hover"/>
<RotatingWord words={t('landing:heroWords', {returnObjects: true}) as string[]}/>

Custom animation from tokens (when no wrapper fits)

import {motion} from 'motion/react'
import {springSnappy} from '@/lib/motion'
 
<motion.button whileHover={{y: -4}} transition={springSnappy}>
    Hover me
</motion.button>

Best practices

  • Reach for a wrapper first. FadeIn / Stagger / Collapse / AnimatedNumber cover most cases and already apply the tokens and reduced-motion handling.
  • Never hardcode timing. Pull DURATION, EASE_OUT, spring* and variants from src/lib/motion.ts. If you need a new value, add it to the tokens file.
  • Memoize any as-polymorphic motion component. motion.create(as) inline in render re-mounts and replays the subtree; wrap in useMemo([as]) (see Stagger / StaggerItem).
  • Guard imperative + scroll-linked motion. animate(), useScroll / useTransform, and clip-path effects bypass MotionConfig, so branch on useReducedMotion() yourself (as AppSplash, Parallax, PageTransition do).
  • Don't reproject layout on tab/panel switches. Wrapping switched content in a keyed motion / layout element re-measures sibling layout / layoutId nodes (Switch thumbs, tab indicators) and flickers the page. Use a plain keyed opacity fade instead.
  • Stagger doesn't cascade through a sliding AnimatePresence overlay. Inside a slideInRight aside (customizer-style), a nested staggerContainer flips straight to visible. For a staggered reveal there, give each child an explicit per-index initial / animate + delay.
  • Colors stay on tokens. FX that paint (ParticleField, DotGrid, WaveDivider, HeroCanvas) read semantic tokens (bg-primary, currentColor, --border) — don't hardcode hex.
  • Demo every new FX on /ui/motion. When you add an FX component to the barrel, add it to MotionShowcasePage too — otherwise it becomes a dead export (four FX once sat unused in the barrel until the showcase page was added).

Troubleshooting

SymptomLikely cause / fix
A component re-animates its entrance on every parent renderAn inline motion(as) / motion.create(as) in render — memoize it with useMemo([as]).
Page transition does nothingconfig.pageTransition is 'none' (the default), or reduced motion is on (forces none). Change it in the Customizer or check OS "Reduce Motion".
Clip-path transitions (clipWipe / iris / doors) look like a plain fadeReduced motion is active — PageTransition intentionally falls back to none; clip-path isn't covered by MotionConfig.
Splash shows every load during developmentsplashFrequency is 'always', or sessionStorage('app-splash-shown') was cleared (e.g. a Reset). Set it to 'once'.
Splash never appearssplashFrequency: 'once' already fired this session — clear sessionStorage('app-splash-shown') or dispatch app:replay-splash.
Lazy page shows a tall blank area, no loaderconfig.routeLoader is off — PageLoader reserves height without a visible loader by design.
Import error / wrong packageYou imported from framer-motion. Import from motion/react only.
An animation ignores OS "Reduce Motion"It uses animate() or a scroll-linked value without a useReducedMotion() branch. Add the guard.

FAQ

Which package do we use — framer-motion or motion? motion — always import from motion/react. framer-motion is never used.

Can I use Motion+ components like <AnimateNumber>? No. We don't have the paid Motion+ tier. Use the free equivalents here — e.g. AnimatedNumber for count-up. Never add motion-plus / motion-plus-react.

How do I disable animations for accessibility? You don't need to — it's automatic. MotionProvider sets reducedMotion="user", so the OS "Reduce Motion" setting strips transforms globally. Just remember to add a useReducedMotion() branch to any imperative or scroll-linked motion you write.

Where do I change the default page transition or loader for everyone? The defaultLayout in src/context/LayoutContext.tsx (pageTransition, pageLoader, routeLoader, splashFrequency). Note that persisted localStorage overrides defaults for existing browsers.

How do I add a new bouncing/curtain effect? See Configuration & customization — add a kind to the relevant *_KINDS array, wire the variant/branch, add the i18n key.

Why is my motion component remounting? Almost always a fresh motion.create(as) type per render. Memoize with useMemo([as]).

Where can I see every effect running? /ui/motion (src/pages/ui/MotionShowcasePage.tsx) — the canonical showcase of every wrapper and FX export, including all five HeroCanvas variants.

Notes for designers & content editors

  • Motion is subtle by default. The shipping default pageTransition is none and entrances are short (DURATION.base = 0.25s). Livelier "curtain" effects (clipWipe, iris, doors, blinds) are opt-in per user via the Customizer / Layout Settings.
  • Everything respects "Reduce Motion." If a user has that OS setting on, animations simplify to fades or static states — there is no separate toggle to maintain.
  • All motion colors come from theme tokens, so effects re-skin automatically across the design skins and light/dark. Nothing is hardcoded to a hex value.
  • Text used in FX is content, not decoration. Typewriter / RotatingWord / ScrambleText take real strings (resolve them through i18n keys, never hardcode UI copy).
  • The splash is branded via SplashMark, which reads the product name/tagline from src/config/brand.ts — update the brand there, not in the loaders.

Was this page helpful?