Motion & Animation
Luminaux's centralized motion system — animation tokens, reusable wrappers and free-core FX, configurable page transitions, and a branded loading experience.
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:
- Import only from
motion/react. The npm package ismotion. Never import fromframer-motion. - 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. - Reduced motion is global and non-negotiable.
MotionProvidersetsreducedMotion="user"; never override it. Any imperativeanimate()or scroll-linked value must also branch onuseReducedMotion()(those bypassMotionConfig). - Tokens are the single source of truth. Pull durations/easings/springs/variants from
src/lib/motion.ts— no inline magic numbers. - Memoize dynamic
motion(as). Creating a motion component inline in render mints a new type each render and remounts (re-animates) the subtree. Usemotion.create(as)wrapped inuseMemo([as])(asStagger/StaggerItemdo).
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
| File | Responsibility |
|---|---|
src/lib/motion.ts | Tokens: durations, easings, springs, variants, PAGE_TRANSITION_KINDS / PAGE_TRANSITIONS, PAGE_LOADER_KINDS, PARALLAX_DISTANCE. |
Wrappers & FX (src/components/motion/)
| File | Responsibility |
|---|---|
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. |
Loading (src/components/loading/)
| File | Responsibility |
|---|---|
AppSplash.tsx | Full-screen branded splash (initial load + replay). |
PageLoader.tsx | Suspense fallback for lazy pages. |
SplashMark.tsx | Shared brand lockup used by both. |
Showcase
| File | Responsibility |
|---|---|
src/pages/ui/MotionShowcasePage.tsx | /ui/motion — the canonical demo of every export above. |
Where they mount:
MotionProviderwraps the app near the root soreducedMotion="user"and the defaultspringtransition apply everywhere.PageTransitionwraps the routed subtree inside anAnimatePresence(keyed per route) in the layout, so navigating fades/wipes/slides the page perconfig.pageTransition.AppSplashis mounted inmain.tsxnext to<App/>, inside the providers so it canuseLayout().PageLoaderis the Suspense fallback forReact.lazypages (declared inApp.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
| Token | Value | Purpose |
|---|---|---|
EASE_OUT | [0.16, 1, 0.3, 1] | Expressive "ease out" curve used for most entrances |
DURATION.fast | 0.15 | Exits, small feedback |
DURATION.base | 0.25 | Default entrances |
DURATION.slow | 0.4 | Clip-path/curtain reveals, scroll reveals |
Springs
| Token | Config | Purpose |
|---|---|---|
spring | stiffness: 300, damping: 30 | Default (set globally via MotionConfig); layout + gestures |
springSnappy | stiffness: 500, damping: 32 | Small interactive feedback (taps, toggles) |
springGentle | stiffness: 200, damping: 22 | Softer shared-element morphs (apps waffle, toast stack) |
Key variants (all are Variants with hidden / visible (+ often exit) states)
| Variant | What it does |
|---|---|
fade | Opacity-only fade in/out |
fadeInUp | Fade + rise (y: 12 → 0) |
scaleIn | Fade + scale (0.96 → 1), spring settle |
slideInLeft / slideInRight | Off-canvas drawer / panel (mobile sidebar, customizer) |
popUp | Pop from an anchored corner (chat widget) — pair with a transform-origin |
staggerContainer | Orchestrates child entrance (staggerChildren: 0.06, delayChildren: 0.04) |
staggerItem | Child of staggerContainer (fade + rise, spring) |
collapse | Expand/collapse (height: auto ↔ 0 + opacity) |
pageVariants | Route-level fade + subtle vertical slide (used by the slide page transition) |
revealInView | Scroll-reveal (fade + rise, DURATION.slow) — used with whileInView |
headerStagger / headerStaggerItem | Header top-bar drop-in cascade |
hoverLift | Hover micro-lift (y: -4, springSnappy) — pair with whileHover |
Other tokens
| Token | Type / value | Purpose |
|---|---|---|
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_TRANSITIONS | Record<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.
| Component | Purpose | Key props |
|---|---|---|
MotionProvider | Global MotionConfig — sets reducedMotion="user" + default spring transition. Never disable. | children |
PageTransition | Wraps route content; the active effect comes from config.pageTransition. Forces none under reduced motion. | children, className? |
Stagger | Container that staggers the entrance of its StaggerItem children. | as? (default motion.div), + motion.div props |
StaggerItem | Child of Stagger; inherits the parent's orchestrated timing. | as?, + motion.div props |
FadeIn | One-off entrance (fade + rise) for standalone elements not in a Stagger. | delay? = 0, + motion.div props |
Collapse | Animated expand/collapse (height: auto ↔ 0) for submenus/accordions. | open, children |
AnimatedNumber | Counts 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? |
SheetModal | Responsive modal — centered dialog on desktop, drag-to-dismiss bottom sheet on mobile. | open, onClose, children, labelledBy?, className? |
Typewriter | Cycles through words, typing then deleting each; static first word under reduced motion. | words, typingSpeed? = 70, pause? = 1400, className? |
ScrambleText | Decodes text from random glyphs to the final string. | text, duration? = 900, trigger?: 'mount'|'hover' = 'mount', className? |
TiltCard | 3D pointer-tilt card (spring-smoothed rotateX/rotateY); no tilt under reduced motion. Used by StatCard (with max={6}). | children, max? = 8, className? |
SwipeRow | Swipe-left-to-act list row; releasing past the threshold fires onSwipe. | children, onSwipe?, actionIcon? = Check, actionLabel?, className? |
CursorGlow | Ambient radial glow that spring-trails the cursor; renders nothing under reduced motion. | size? = 420, className? |
WarpOverlay | Full-screen overlay revealing content with a clip-path warp (center → out); backdrop + Esc close. | open, onClose, children, className? |
ImageRevealSlider | Before/after image comparison with a draggable, keyboard-accessible handle. | before, after, beforeAlt? = '', afterAlt? = '', className? |
ScrollRevealLines | Reveals text lines one-by-one as they scroll into view (per-line delay). | lines, lineClassName?, className? |
Reveal | Scroll-reveal wrapper — fades + rises children into view once (uses revealInView + REVEAL_MARGIN). | + motion.div props (no custom props) |
Parallax | Scroll-parallax — drifts children on Y through the viewport; disabled under reduced motion. | children, distance? = PARALLAX_DISTANCE.band, className? |
RotatingWord | Headline word cycling through a list (spring up / slide out); static first word under reduced motion. | words, interval? = 2400, className? |
ParticleField | Ambient floating-particle background of soft token-colored dots; static under reduced motion. | count? = 28, dotClassName? = 'bg-primary', className? |
DotGrid | Decorative token-colored dot-grid texture (radial gradient of --border dots). | size? = 24, masked? = false, className? |
WaveDivider | Full-width decorative wave divider — two layered, slowly-drifting SVG waves. Paints in currentColor. | flip?, heightClassName? = 'h-12 sm:h-20', animated? = true, className? |
HeroCanvas | Reusable 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? |
Info
HeroCanvas exports the helper types HeroCanvasVariant, HeroCanvasTone
('primary' | 'info' | 'success' | 'warning' | 'danger') and LinesConfig for fine-tuning the
'lines' variant geometry. The shader / lines variants paint a real 2D <canvas> that reads the
raw token vars via useChartTokens (so they re-skin live); under reduced motion every canvas paints
one static frame instead of a rAF loop. parallax (true = PARALLAX_DISTANCE.hero, or a px
number) drifts the layer on scroll-out — the parent section must be relative, and content should sit
in a relative z-10 wrapper. All five variants are shown side-by-side on /ui/motion.
Loading components
From src/components/loading/ — driven by the config flags in the next section.
| Component | Purpose | Notes |
|---|---|---|
AppSplash | Full-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. |
PageLoader | Suspense fallback for lazy in-app pages. | Style follows config.pageLoader; when config.routeLoader is off it reserves height without a visible loader. |
SplashMark | Shared 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:
| Flag | Type | Default | Effect |
|---|---|---|---|
pageTransition | PageTransitionKind | 'none' | Route-change effect (PageTransition) |
pageLoader | PageLoaderKind | 'branded' | Splash + route-loader visual style |
routeLoader | boolean | true | Show 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
- Add the kind to
PAGE_TRANSITION_KINDSinsrc/lib/motion.ts. - Add its
Variants(clip-path / transform only — no Motion+) and register it in thePAGE_TRANSITIONSmap. - Add a
pageTransition<Cap>i18n key tocustomizer.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
- Add the style to
PAGE_LOADER_KINDSinsrc/lib/motion.ts. - Add a branch for it in both
AppSplash.tsxandPageLoader.tsx. - 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/AnimatedNumbercover most cases and already apply the tokens and reduced-motion handling. - Never hardcode timing. Pull
DURATION,EASE_OUT,spring*and variants fromsrc/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 inuseMemo([as])(seeStagger/StaggerItem). - Guard imperative + scroll-linked motion.
animate(),useScroll/useTransform, and clip-path effects bypassMotionConfig, so branch onuseReducedMotion()yourself (asAppSplash,Parallax,PageTransitiondo). - Don't reproject layout on tab/panel switches. Wrapping switched content in a keyed
motion/layoutelement re-measures siblinglayout/layoutIdnodes (Switch thumbs, tab indicators) and flickers the page. Use a plain keyed opacity fade instead. - Stagger doesn't cascade through a sliding
AnimatePresenceoverlay. Inside aslideInRightaside (customizer-style), a nestedstaggerContainerflips straight tovisible. For a staggered reveal there, give each child an explicit per-indexinitial/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 toMotionShowcasePagetoo — otherwise it becomes a dead export (four FX once sat unused in the barrel until the showcase page was added).
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| A component re-animates its entrance on every parent render | An inline motion(as) / motion.create(as) in render — memoize it with useMemo([as]). |
| Page transition does nothing | config.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 fade | Reduced motion is active — PageTransition intentionally falls back to none; clip-path isn't covered by MotionConfig. |
| Splash shows every load during development | splashFrequency is 'always', or sessionStorage('app-splash-shown') was cleared (e.g. a Reset). Set it to 'once'. |
| Splash never appears | splashFrequency: 'once' already fired this session — clear sessionStorage('app-splash-shown') or dispatch app:replay-splash. |
| Lazy page shows a tall blank area, no loader | config.routeLoader is off — PageLoader reserves height without a visible loader by design. |
| Import error / wrong package | You 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
pageTransitionisnoneand 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/ScrambleTexttake real strings (resolve them through i18n keys, never hardcode UI copy). - The splash is branded via
SplashMark, which reads the product name/tagline fromsrc/config/brand.ts— update the brand there, not in the loaders.
Related
Getting Started
Stack, scripts, and conventions.
Architecture & Routing
Provider tree, lazy routes, and Suspense.
Layout & Shell
Where the motion config flags live on the shell.
Customizer & Settings
The UI that edits pageTransition / pageLoader / routeLoader.
Design Tokens & Dark Mode
The color tokens the FX paint with.
Was this page helpful?
