From 7e41670948f409bdff83fa76aabbee96ab2e2767 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:04:39 +0100 Subject: [PATCH 1/3] perf(landing): paint the hero copy from the server HTML and defer the decorative scene --- apps/landing/src/app/globals.css | 62 ++++ .../src/components/HomepageHero/index.tsx | 268 +++++++----------- .../src/components/TextAnimation/BlurText.tsx | 132 --------- .../components/TextAnimation/RisingWords.tsx | 54 ++++ .../components/UploaderScene/DragGhost.tsx | 6 + .../UploaderScene/MockDriveBrowser.tsx | 8 +- .../components/UploaderScene/MockUploader.tsx | 11 +- .../components/UploaderScene/scene-media.ts | 34 +++ 8 files changed, 268 insertions(+), 307 deletions(-) delete mode 100644 apps/landing/src/components/TextAnimation/BlurText.tsx create mode 100644 apps/landing/src/components/TextAnimation/RisingWords.tsx diff --git a/apps/landing/src/app/globals.css b/apps/landing/src/app/globals.css index 050d57a57..59f4f2f75 100644 --- a/apps/landing/src/app/globals.css +++ b/apps/landing/src/app/globals.css @@ -26,6 +26,68 @@ html { scroll-behavior: smooth; } + +/* ── Hero entrance — CSS only, never JS ─────────────────────────── + The hero copy (badge, H1, subtitle, CTAs, install box) used to enter + through framer-motion `initial="hidden"`, which means the SERVER HTML + ships it at opacity 0 and it only becomes visible once hydration has + finished. On a throttled phone that was 92% of a 10.5 s LCP, all of it + "render delay" — the text was in the HTML the whole time, just painted + transparent. A CSS animation runs off the server HTML instead, so the + first paint already carries the copy. + Stagger with an inline `--hero-delay`; keep every delay short, because + the element is invisible until its delay elapses (fill-mode `both`) and + the subtitle is the LCP element. */ +@keyframes hero-rise { + from { + opacity: 0; + transform: translate3d(0, 18px, 0); + } + to { + opacity: 1; + transform: none; + } +} + +/* Transform-only twin, for the H1 and the subtitle. An element at opacity 0 + has not painted, so fading in the LCP text pushes LCP out by the fade's + delay AND duration — measured at ~+1.9 s on throttled mobile even though the + text was in the server HTML the whole time. These elements are therefore + painted at full opacity from the first frame and only slide into place. */ +@keyframes hero-lift { + from { + transform: translate3d(0, 12px, 0); + } + to { + transform: none; + } +} + +.hero-rise { + animation: hero-rise 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation-delay: var(--hero-delay, 0s); +} + +.hero-lift { + animation: hero-lift 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation-delay: var(--hero-delay, 0s); +} + +/* Per-word variant for the H1 (the old BlurText). Words are plain text nodes + either way — this only slides them in. */ +.hero-word { + display: inline-block; + animation: hero-lift 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + animation-delay: var(--hero-delay, 0s); +} + +@media (prefers-reduced-motion: reduce) { + .hero-rise, + .hero-lift, + .hero-word { + animation: none; + } +} body { background: var(--bg-base); color: var(--fg-base); diff --git a/apps/landing/src/components/HomepageHero/index.tsx b/apps/landing/src/components/HomepageHero/index.tsx index ad56cb2e7..892bed6d9 100644 --- a/apps/landing/src/components/HomepageHero/index.tsx +++ b/apps/landing/src/components/HomepageHero/index.tsx @@ -12,14 +12,40 @@ import { ChevronDown, } from 'lucide-react' import { motion, AnimatePresence, useInView } from 'framer-motion' +import dynamic from 'next/dynamic' import GradientText from '@/components/TextAnimation/GradientText' -import BlurText from '@/components/TextAnimation/BlurText' +import RisingWords from '@/components/TextAnimation/RisingWords' import FrameworkSnippets from '@/components/FrameworkSnippets' import FrameworkStrip from '@/components/FrameworkStrip' -import { HeroSession } from '@/components/UploaderScene' import { FRAMEWORKS, type FrameworkId } from '@/lib/frameworks' import { useCopyToClipboard } from '@/lib/use-copy-to-clipboard' +// The hero visual is decorative (aria-hidden) and expensive: the scene engine, +// a pile of react-icons, and eleven tags that React 19 hoists into +// `` at the very top of the document, ahead of +// the CSS the hero COPY needs. None of it is indexable, so it loads client-side +// only, behind the same viewport gate the scene already used for its timeline. +// The placeholder reserves the box so nothing shifts when it arrives. +const HeroSession = dynamic( + () => import('@/components/UploaderScene/HeroSession'), + { + ssr: false, + loading: () => , + }, +) + +// Matches HeroSession's own root box (mx-auto, max-w-[440px]) and its measured +// height — 735px at a 412px viewport, 743px at desktop — so mounting the real +// scene is a zero-shift swap. +function HeroVisualPlaceholder() { + return ( + - - + + {/* RIGHT — the live-usage animation as the hero's visual anchor. Decorative, so it carries no copy; the left - column holds all the info. */} - - - + column holds all the info. Client-only and mounted the + first time the box reaches the viewport. */} +
+ {visualMounted ? ( + + ) : ( + + )} +
{/* Full-width below the grid: framework strip, then the diff --git a/apps/landing/src/components/TextAnimation/BlurText.tsx b/apps/landing/src/components/TextAnimation/BlurText.tsx deleted file mode 100644 index b6825fb0e..000000000 --- a/apps/landing/src/components/TextAnimation/BlurText.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { motion, Transition } from "framer-motion"; -import { useEffect, useRef, useState, useMemo } from "react"; - -type BlurTextProps = { - text?: string; - delay?: number; - className?: string; - animateBy?: "words" | "letters"; - direction?: "top" | "bottom"; - threshold?: number; - rootMargin?: string; - animationFrom?: Record; - animationTo?: Array>; - easing?: (t: number) => number; - onAnimationComplete?: () => void; - stepDuration?: number; -}; - -const buildKeyframes = ( - from: Record, - steps: Array> -): Record> => { - const keys = new Set([ - ...Object.keys(from), - ...steps.flatMap((s) => Object.keys(s)), - ]); - - const keyframes: Record> = {}; - keys.forEach((k) => { - keyframes[k] = [from[k], ...steps.map((s) => s[k])]; - }); - return keyframes; -}; - -const BlurText: React.FC = ({ - text = "", - delay = 200, - className = "", - animateBy = "words", - direction = "top", - threshold = 0.1, - rootMargin = "0px", - animationFrom, - animationTo, - easing = (t) => t, - onAnimationComplete, - stepDuration = 0.35, - }) => { - const elements = animateBy === "words" ? text.split(" ") : text.split(""); - const [inView, setInView] = useState(false); - const ref = useRef(null); - - useEffect(() => { - if (!ref.current) return; - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setInView(true); - observer.unobserve(ref.current as Element); - } - }, - { threshold, rootMargin } - ); - observer.observe(ref.current); - return () => observer.disconnect(); - }, [threshold, rootMargin]); - - const defaultFrom = useMemo( - () => - direction === "top" - ? { filter: "blur(10px)", opacity: 0, y: -50 } - : { filter: "blur(10px)", opacity: 0, y: 50 }, - [direction] - ); - - const defaultTo = useMemo( - () => [ - { - filter: "blur(5px)", - opacity: 0.5, - y: direction === "top" ? 5 : -5, - }, - { filter: "blur(0px)", opacity: 1, y: 0 }, - ], - [direction] - ); - - const fromSnapshot = animationFrom ?? defaultFrom; - const toSnapshots = animationTo ?? defaultTo; - - const stepCount = toSnapshots.length + 1; - const totalDuration = stepDuration * (stepCount - 1); - const times = Array.from({ length: stepCount }, (_, i) => - stepCount === 1 ? 0 : i / (stepCount - 1) - ); - - return ( -

- {elements.map((segment, index) => { - const animateKeyframes = buildKeyframes(fromSnapshot, toSnapshots); - - const spanTransition: Transition = { - duration: totalDuration, - times, - delay: (index * delay) / 1000, - }; - (spanTransition as any).ease = easing; - - return ( - - {segment === " " ? "\u00A0" : segment} - {animateBy === "words" && index < elements.length - 1 && "\u00A0"} - - ); - })} -

- ); -}; - -export default BlurText; diff --git a/apps/landing/src/components/TextAnimation/RisingWords.tsx b/apps/landing/src/components/TextAnimation/RisingWords.tsx new file mode 100644 index 000000000..376513bc8 --- /dev/null +++ b/apps/landing/src/components/TextAnimation/RisingWords.tsx @@ -0,0 +1,54 @@ +import type { CSSProperties } from 'react' + +// RisingWords — the hero H1's per-word entrance. +// +// This replaces the old framer-motion `BlurText`, which rendered every word as +// a `motion.span` with an inline `opacity: 0` initial style behind an +// IntersectionObserver. That put the H1 — server-rendered, indexable text — at +// zero opacity in the HTML until framer had hydrated and the observer had +// fired, which on a throttled phone is seconds of pure render delay. +// +// Here the words are plain text nodes in the server HTML; a CSS keyframe +// (`.hero-word` in app/globals.css, with a per-word `--hero-delay`) animates +// them in. No framer, no observer, no `will-change`, and +// `prefers-reduced-motion: reduce` turns the animation off in the same +// stylesheet. The inter-word gap is a non-breaking space inside each word's +// span, exactly as before: the wrapper is a flex row, so a normal trailing +// space would collapse away between flex items. + +export default function RisingWords({ + text, + className = '', + /** Per-word stagger, in milliseconds. */ + stagger = 60, + /** Delay before the first word, in milliseconds. */ + startDelay = 0, +}: Readonly<{ + text: string + className?: string + stagger?: number + startDelay?: number +}>) { + const words = text.split(' ') + + return ( + + {words.map((word, index) => ( + + {word} + {index < words.length - 1 && '\u00A0'} + + ))} + + ) +} diff --git a/apps/landing/src/components/UploaderScene/DragGhost.tsx b/apps/landing/src/components/UploaderScene/DragGhost.tsx index 007611b6d..e94d20cf8 100644 --- a/apps/landing/src/components/UploaderScene/DragGhost.tsx +++ b/apps/landing/src/components/UploaderScene/DragGhost.tsx @@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { FaRegFolderOpen } from 'react-icons/fa' +import { sceneImageSize } from './scene-media' // ───────────────────────────────────────────────────────────────────────────── // DragGhost — the "thing being dragged" the timeline glides across the panel: the @@ -116,6 +117,11 @@ export default function DragGhost({ )} diff --git a/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx b/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx index 4e052aa16..60c61a7cb 100644 --- a/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx +++ b/apps/landing/src/components/UploaderScene/MockDriveBrowser.tsx @@ -4,7 +4,7 @@ import type { CSSProperties } from 'react' import { useEffect, useRef } from 'react' import { motion } from 'framer-motion' import { FaCheck, FaFolderOpen, FaPlay } from 'react-icons/fa' -import { SCENE_MEDIA } from './scene-media' +import { SCENE_MEDIA, sceneImageSize } from './scene-media' import type { DriveProvider, DriveThumb } from './types' // ───────────────────────────────────────────────────────────────────────────── @@ -49,6 +49,9 @@ export default function MockDriveBrowser({ @@ -173,6 +176,9 @@ function ThumbMedia({ thumb, reduce }: { thumb: DriveThumb; reduce: boolean }) { ) diff --git a/apps/landing/src/components/UploaderScene/MockUploader.tsx b/apps/landing/src/components/UploaderScene/MockUploader.tsx index b926a6ae5..0a65595b1 100644 --- a/apps/landing/src/components/UploaderScene/MockUploader.tsx +++ b/apps/landing/src/components/UploaderScene/MockUploader.tsx @@ -14,7 +14,7 @@ import { } from 'react-icons/fa' import { SiGoogledrive, SiDropbox, SiBox } from 'react-icons/si' import { GrOnedrive } from 'react-icons/gr' -import { SCENE_MEDIA } from './scene-media' +import { SCENE_MEDIA, sceneImageSize } from './scene-media' import type { QueueFile, QueueStage, SourceDef } from './types' // ───────────────────────────────────────────────────────────────────────────── @@ -255,6 +255,9 @@ export default function MockUploader({ @@ -262,6 +265,9 @@ export default function MockUploader({ @@ -428,6 +434,9 @@ function FileThumb({ file }: { file: QueueFile }) { diff --git a/apps/landing/src/components/UploaderScene/scene-media.ts b/apps/landing/src/components/UploaderScene/scene-media.ts index ef9f25837..73e84c258 100644 --- a/apps/landing/src/components/UploaderScene/scene-media.ts +++ b/apps/landing/src/components/UploaderScene/scene-media.ts @@ -35,3 +35,37 @@ export const SCENE_MEDIA = { devino: '/devino.png', }, } as const + +// Every stock photo in the kit was exported at this size; the two video poster +// frames are 16:9 and the two logos are their own shapes. +const DEFAULT_PHOTO_SIZE = { width: 400, height: 300 } as const + +const IMAGE_SIZES: Readonly< + Record +> = { + [SCENE_MEDIA.videos.beachWaves.poster]: { width: 640, height: 360 }, + [SCENE_MEDIA.videos.screenShare.poster]: { width: 640, height: 360 }, + [SCENE_MEDIA.logos.upup]: { width: 3200, height: 679 }, + [SCENE_MEDIA.logos.devino]: { width: 1905, height: 580 }, +} + +/** + * Intrinsic pixel dimensions for a scene asset, so every scene `` can + * carry `width`/`height`. Two reasons they are not optional: + * + * - Lighthouse flags width/height-less images as a layout-shift risk on every + * page a scene renders on. + * - React 19 hoists an eagerly-loaded `` into a `` at the top of the document. Eleven decorative scene photos + * were therefore being preloaded ahead of the CSS and fonts the hero COPY + * needs; pairing these attributes with `loading="lazy"` stops that. + * + * The scene images are all `object-cover` inside absolutely-positioned boxes, + * so the attributes never change layout — they only describe the file. + */ +export function sceneImageSize(src: string | undefined): { + readonly width: number + readonly height: number +} { + return (src && IMAGE_SIZES[src]) || DEFAULT_PHOTO_SIZE +} From 7b37913f3efe16e2b59fe479970ae4e6406cc791 Mon Sep 17 00:00:00 2001 From: Alaeddin <15094821+BSalaeddin@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:16:00 +0100 Subject: [PATCH 2/3] perf(landing): load the live demo, StackBlitz editor, feature scenes and analytics on demand --- apps/e2e-test/landing/thumbs-flow.spec.ts | 7 + apps/landing/src/app/[framework]/page.tsx | 4 +- apps/landing/src/app/layout.tsx | 6 +- apps/landing/src/app/page.tsx | 4 +- .../components/DeferredInteractiveExample.tsx | 76 ++++++++++ .../src/components/FeatureShowcase/index.tsx | 57 ++++++-- .../StackBlitzDemoSection/index.tsx | 135 +++++++++++------- apps/landing/src/components/providers.tsx | 15 +- 8 files changed, 230 insertions(+), 74 deletions(-) create mode 100644 apps/landing/src/components/DeferredInteractiveExample.tsx diff --git a/apps/e2e-test/landing/thumbs-flow.spec.ts b/apps/e2e-test/landing/thumbs-flow.spec.ts index ada1b3840..fbea39467 100644 --- a/apps/e2e-test/landing/thumbs-flow.spec.ts +++ b/apps/e2e-test/landing/thumbs-flow.spec.ts @@ -168,6 +168,13 @@ test.describe('Ask AI thumbs feedback', () => { ) .toBe(true) + // The demo section is client-only and mounts the first time it comes + // within ~400px of the viewport (it is the heaviest thing on the page, + // and on a phone it costs seconds of main-thread time nobody who never + // scrolls to it should pay). Scroll it into view so the Ask-AI panel + // below actually exists. + await page.locator('#demo').scrollIntoViewIfNeeded() + // Ask one short question and wait for the assistant's completed turn. const panel = page.locator('.upup-ie-ai-panel') const input = panel.locator('#upup-ai-message') diff --git a/apps/landing/src/app/[framework]/page.tsx b/apps/landing/src/app/[framework]/page.tsx index 000eb7825..57b244d07 100644 --- a/apps/landing/src/app/[framework]/page.tsx +++ b/apps/landing/src/app/[framework]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next' import { notFound } from 'next/navigation' import '@useupup/interactive-example/styles' -import { InteractiveExampleClient } from '@/components/InteractiveExampleClient' +import DeferredInteractiveExample from '@/components/DeferredInteractiveExample' import { interactiveExampleEnvProps } from '@/lib/interactive-example-props' import { FRAMEWORK_IDS, getFramework } from '@/lib/frameworks' import StructuredData from '@/components/StructuredData' @@ -73,7 +73,7 @@ export default async function FrameworkPage({ />
- {process.env.NODE_ENV === 'production' && ( <> + {/* lazyOnload: Hotjar is session-recording, never + needed for the page to work, and on a throttled + phone its loader competed with hydration for the + main thread. */}