PVR Tech Studio

Animation & Effects

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.

13 min read
Updated August 13, 2026


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

src/lib/motion.ts                     # tokens: durations, easings, springs, variants,
                                      #         PAGE_TRANSITION_KINDS / PAGE_TRANSITIONS,
                                      #         PAGE_LOADER_KINDS, PARALLAX_DISTANCE
src/components/motion/
  index.ts                            # barrel — re-exports every wrapper + FX component
  MotionProvider.tsx                  # global MotionConfig (reducedMotion="user")
  PageTransition.tsx                  # route-change transition (driven by config.pageTransition)
  Stagger.tsx / StaggerItem.tsx       # orchestrated child entrance
  FadeIn.tsx                          # one-off fade + rise
  Collapse.tsx                        # height auto <-> 0
  AnimatedNumber.tsx                  # count-up (free AnimateNumber equivalent)
  SheetModal.tsx                      # responsive dialog / drag-to-dismiss bottom sheet
  Typewriter.tsx  ScrambleText.tsx    # text FX
  TiltCard.tsx  SwipeRow.tsx          # gesture FX
  CursorGlow.tsx  WarpOverlay.tsx     # ambient / overlay FX
  ImageRevealSlider.tsx               # before/after comparison
  ScrollRevealLines.tsx  Reveal.tsx   # scroll reveals
  Parallax.tsx  RotatingWord.tsx      # scroll drift / headline cycle
  ParticleField.tsx  DotGrid.tsx      # decorative backgrounds
  WaveDivider.tsx  HeroCanvas.tsx     # section decor / hero background
src/components/loading/
  AppSplash.tsx                       # full-screen branded splash (initial load + replay)
  PageLoader.tsx                      # Suspense fallback for lazy pages
  SplashMark.tsx                      # shared brand lockup used by both
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 at the root beside the routes, inside the providers so it can useLayout().
  • PageLoader is the fallback shown while a code-split page loads.

The motion config flags (pageTransition, pageLoader, routeLoader, splashFrequency) live on LayoutContext alongside the rest of the shell config — see Layout System and Customizer & Layout 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}}
/>

API / Props

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/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={asset('/photos/photo-1.jpg')} 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?