STRIDE: Kinetic Preloader & View Transitions
Engineering an editorial kinetic typography preloader with GSAP slot-machine scrubbing and seamless route changes via React 19 View Transitions.

I am Saurabh Thapliyal, and in this breakdown I deconstruct the motion engineering behind STRIDE—a high-fashion footwear commerce experience designed with an editorial sensibility. Here, I examine how we achieved a memorable brand introduction with a custom kinetic typography preloader, paired with seamless, zero-layout-shift page navigations powered by React 19 and Next.js 16 native View Transitions.
Context & Editorial Philosophy
In high-end luxury e-commerce, the first 3 seconds dictate user perception. Standard loading spinners or circular progress meters break the spell of haute couture, signaling generic technical machinery rather than bespoke craftsmanship. For STRIDE, the goal was to build a preloader that feels like an analog airport split-flap display or a luxury watchmaker's mechanical complication—tactile, rhythmic, and mesmerizing.
Equally critical was the route navigation experience. Traditional Single Page Applications (SPAs) often suffer from abrupt DOM swaps, white flashes, or heavy JavaScript transition libraries like Framer Motion that bloat the client bundle and trigger re-renders across the entire tree. With Next.js 16 and React 19, we engineered a native, hardware-accelerated transition system that offloads layout interpolation entirely to the browser's compositor thread.
Project Architecture
To maintain a clean separation of concerns, the project isolates loading synchronization, view transition routing boundaries, and inertial momentum scrolling into lean, modular layers:
stride/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout & font definitions
│ │ ├── template.tsx # React 19 native <ViewTransition> boundary
│ │ ├── page.tsx # Home landing & Lenis lock sync
│ │ └── globals.css # ::view-transition-old / ::view-transition-new
│ ├── components/
│ │ ├── Header/ # Shared luxury sticky navigation header
│ │ ├── Landing/ # Editorial showcase & interactive elements
│ │ ├── Lenis/ # Smooth momentum scrolling provider
│ │ └── Preloader/
│ │ ├── index.tsx # Dual-slot GSAP letter roll & exit staging
│ │ └── style.module.scss # Overflow masking & responsive typography
│ ├── context/
│ │ └── PreloaderContext.tsx # React context controlling ready state
│ └── hooks/
│ └── useIsMounted.ts # SSR hydration safety guard
└── next.config.ts # experimental.viewTransition: trueThe root layout mounts PreloaderProvider and LenisProvider. Crucially, the route transition boundary is housed in template.tsx rather thanlayout.tsx, ensuring that each page change mounts a fresh view transition snapshot.
Kinetic Character Slot Tumbling
The wordmark "STRIDE" is rendered using the expressive serif typeface Le Murmure. Rather than translating the entire word as a monolithic block, each letter is isolated inside an individual .char masking slot containing two synchronized DOM elements: an .originalText node and a .cloneText node.
To create a rich mechanical rhythm, adjacent letters alternate their tumbling direction based on character index parity (i % 2 === 0). Even columns tumble downwards, while odd columns tumble upwards.
// Dual-element character slot tumbling with parity-driven counter-rotation
const chars = gsap.utils.toArray<HTMLSpanElement>(`.${styles.char}`);
chars.forEach((char, i) => {
const original = char.querySelector<HTMLDivElement>(`.${styles.originalText}`);
const clone = char.querySelector<HTMLDivElement>(`.${styles.cloneText}`);
if (!original || !clone) return;
const isEven = i % 2 === 0;
// Alternate initial offset so adjacent letters roll in opposite directions
gsap.set(clone, {
yPercent: isEven ? -100 : 100,
});
// Create infinite tumbling cycle per character
const roll = gsap.to([original, clone], {
repeat: LOOP_COUNT, // 8 continuous revolutions
ease: "none",
yPercent: isEven ? "+=100" : "-=100",
duration: 1,
});
tl.add(roll, 0);
});By stacking .originalText and .cloneText with position: absoluteand height: 1em inside an overflow: hidden container, translating both by100% creates an imperceptible modulo wrap. The viewer perceives a continuous roller of letters without needing thousands of DOM nodes or high-overhead canvas rendering.
Inertial Deceleration Scrubbing (power4.out)
A frequent pitfall in slot-machine animations is trying to time each character independently with easing curves. Independent eases fall out of lockstep, causing irregular spacing and jarring halts.
In STRIDE, we solved this with a master scrub technique:
- All 6 character rolls are added to a paused sub-timeline
tlwith linear progression (ease: "none") repeating 8 times. - A master tween animates the sub-timeline's
progressproperty from0to1over 4 seconds, driven by a heavypower4.outcurve.
// Inertial kinetic deceleration scrubbing over the linear loop
gsap.to(tl, {
progress: 1,
duration: 4,
ease: "power4.out", // Exponential spin-down settling into exact brand letters
onComplete: () => {
const exitTl = gsap.timeline();
// 1. Text lifts out of masked viewport bounds
exitTl.to(headingRef.current, {
yPercent: -110,
duration: 0.8,
ease: "power4.inOut",
})
// 2. Fullscreen cream background curtain wipes upward
.to(preloaderRef.current, {
yPercent: -100,
duration: 0.5,
ease: "power4.inOut",
onComplete: () => {
// Discard preloader from render tree to unblock mouse interactions
if (preloaderRef.current) {
preloaderRef.current.style.display = "none";
}
onComplete?.(); // Notify PreloaderContext to unlock Lenis scroll
},
}, "-=0.4");
},
});Because the sub-timeline is linear, the master tween directly sculpts the velocity profile. The animation begins at blisteringly high RPM, then naturally expends its kinetic energy over 4 seconds, gracefully clicking into the final wordmark at zero velocity.
Exit Staging & Lenis Concurrency
Once the brand title snaps into focus, the exit choreography unmasks the landing page in two staged beats:
- Headline Lift: The heading translates up to
yPercent: -110withpower4.inOutover 0.8s, disappearing behind the page's top threshold. - Curtain Wipe: With a 0.4s negative stagger (
"-=0.4"), the entire cream background container slides up toyPercent: -100over 0.5s, unveiling the editorial hero.
To prevent accidental scrolling while the preloader is executing, the landing page synchronizes the viewport scroll state directly with the preloader lifecycle:
// src/app/page.tsx - Synchronizing preloader completion with Lenis smooth momentum scroll
export default function Home() {
const { isReady, setIsReady } = usePreloader();
useEffect(() => {
const handleLenis = () => {
const lenis = (window as unknown as { lenis?: Lenis }).lenis;
if (!isReady) {
document.body.style.overflow = "hidden";
if (lenis) lenis.stop(); // Freeze momentum during preloader
} else {
document.body.style.overflow = "";
if (lenis) lenis.start(); // Restore buttery smooth scroll
}
};
handleLenis();
window.addEventListener("lenis-ready", handleLenis);
return () => {
document.body.style.overflow = "";
const lenis = (window as unknown as { lenis?: Lenis }).lenis;
if (lenis) lenis.start();
window.removeEventListener("lenis-ready", handleLenis);
};
}, [isReady]);
return (
<main className={styles.page}>
<Preloader onComplete={() => setIsReady(true)} />
<Landing isReady={isReady} />
</main>
);
}While !isReady, lenis.stop() and overflow: hidden guarantee that scroll inputs never trigger early image lazy-loading or misaligned canvas coordinates. Once completed,lenis.start() smoothly unlocks inertial momentum navigation.
Next.js 16 & React 19 View Transitions
Traditional Next.js page transitions typically require wrapping pages in AnimatePresence, which delays unmounting, duplicates DOM trees in memory, and causes severe layout jumping on mobile browsers.
With the advent of the Navigation and View Transition API, Next.js 16 allows native browser snapshotting of page navigations. We enabled this feature in next.config.ts:
// next.config.ts - Next.js 16 native View Transition flag
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactCompiler: true,
experimental: {
viewTransition: true, // Integrates Next.js App Router with View Transition API
},
};
export default nextConfig;In the Next.js App Router, layout.tsx preserves state and persists across navigations. To capture route changes, we wrapped our page children inside src/app/template.tsx using React 19's experimental <ViewTransition> primitive:
// src/app/template.tsx - React 19 ViewTransition boundary per route change
"use client";
import React, { ViewTransition } from "react";
interface TemplateProps {
children: React.ReactNode;
}
export default function Template({ children }: TemplateProps) {
return (
<ViewTransition enter="page-enter" exit="page-exit">
{children}
</ViewTransition>
);
}Next.js templates instantiate a brand-new component instance whenever the URL changes, whereas layouts remain static. This re-instantiation allows React 19 to demarcate the exact outgoing and incoming fiber subtrees, informing the browser to invoke document.startViewTransition()without any manual state hooks.
CSS State Interpolation & Navbar Isolation
Once the browser captures the outgoing and incoming snapshots, it constructs a temporary pseudo-element tree:::view-transition-old and ::view-transition-new.
In src/app/globals.css, we defined custom cinematic animations and preserved the brand navigation:
/* src/app/globals.css - Pure CSS View Transition Pseudo-Elements */
/* 1. Freeze navbar in place across route transitions */
::view-transition-group(navbar) {
animation: none;
z-index: 100;
}
/* 2. Animate outgoing page snapshot */
::view-transition-old(.page-exit) {
animation: 600ms cubic-bezier(0.75, 0, 0.1, 1) both page-out;
}
/* 3. Animate incoming page snapshot */
::view-transition-new(.page-enter) {
animation: 600ms cubic-bezier(0.75, 0, 0.1, 1) both page-in;
}
@keyframes page-in {
from {
transform: translateY(15%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes page-out {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-8%);
opacity: 0;
}
}Two details make this transition exceptionally polished:
- Navbar Isolation: By declaring
::view-transition-group(navbar) { animation: none; z-index: 100; }, the sticky header is excluded from page cross-fades and remains rock-solid while the editorial content glides underneath. - Asymmetric Timing: The outgoing page fades out and retracts slightly upwards (
translateY(-8%)), while the incoming page glides in fromtranslateY(15%)using a high-tension cubic bezier (cubic-bezier(0.75, 0, 0.1, 1)).
Architecture & Performance Comparison
Migrating from traditional JS transition wrappers to Native View Transitions yields dramatic performance and maintainability dividends:
• 0 kB client JS runtime overhead
• GPU compositor hardware accelerated
• No unmount delay or double-DOM memory spikes
• Declarative CSS pseudo-element customization
• Chrome/Edge/Safari cross-platform support
• 35+ kB runtime payload (e.g. Framer Motion)
• Main-thread JS animation execution
• Unmount holding locks next page renders
• Scroll jump & layout thrashing bugs
• Fragile wrapper nesting in App Router
