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 (
+
+ )
+}
+
export default function HeroSection({
framework,
}: Readonly<{ framework?: FrameworkId }> = {}) {
@@ -36,6 +62,12 @@ export default function HeroSection({
// HeroSession — we just pass the gate down.
const visualRef = useRef(null)
const visualActive = useInView(visualRef, { amount: 0.2 })
+ // …and a one-way latch on top of it, so the scene's chunk is fetched only
+ // once the box has actually been on screen, and stays mounted after that.
+ const [visualMounted, setVisualMounted] = useState(false)
+ useEffect(() => {
+ if (visualActive) setVisualMounted(true)
+ }, [visualActive])
const pkg = fw?.pkg ?? '@useupup/react'
const packageManagers = useMemo(
@@ -92,111 +124,16 @@ export default function HeroSection({
const easeCurve: [number, number, number, number] = [0.25, 0.46, 0.45, 0.94]
- // Animation variants — y/opacity/scale only (no x-slides; the section is
- // overflow-hidden and clips horizontal entrances at narrow viewports).
- const containerVariants = {
- hidden: { opacity: 0 },
- visible: {
- opacity: 1,
- transition: {
- staggerChildren: 0.15,
- delayChildren: 0.2,
- },
- },
- }
-
- const itemVariants = {
- hidden: { opacity: 0, y: 30, scale: 0.95 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.7,
- ease: easeCurve,
- },
- },
- }
-
- const badgeVariants = {
- hidden: { opacity: 0, y: -20, scale: 0.8 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.6,
- ease: easeCurve,
- },
- },
- }
-
- const headingVariants = {
- hidden: { opacity: 0, y: 40 },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- duration: 0.8,
- ease: easeCurve,
- delay: 0.2,
- },
- },
- }
-
- const subtitleVariants = {
- hidden: { opacity: 0, y: 30 },
- visible: {
- opacity: 1,
- y: 0,
- transition: {
- duration: 0.7,
- ease: easeCurve,
- delay: 0.4,
- },
- },
- }
-
- const buttonVariants = {
- hidden: { opacity: 0, y: 20, scale: 0.95 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.6,
- ease: easeCurve,
- },
- },
- }
-
- const installBoxVariants = {
- hidden: { opacity: 0, y: 30, scale: 0.9 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.7,
- ease: easeCurve,
- delay: 0.8,
- },
- },
- }
-
- const visualVariants = {
- hidden: { opacity: 0, y: 40, scale: 0.96 },
- visible: {
- opacity: 1,
- y: 0,
- scale: 1,
- transition: {
- duration: 0.9,
- ease: easeCurve,
- delay: 0.3,
- },
- },
- }
+ // The hero copy's entrance is CSS (`.hero-rise` / `.hero-word` in
+ // globals.css), staggered by an inline `--hero-delay`. It used to be a
+ // framer-motion `initial="hidden"` variant tree, which is why the server
+ // HTML shipped the H1, subtitle, CTAs and install box at opacity 0 — on a
+ // throttled phone the LCP text waited for hydration before it painted at
+ // all. Delays stay short and the subtitle (the LCP element) is close to
+ // first paint. Only whileHover/whileTap motion survives here, and only on
+ // elements with no `initial`, so nothing starts invisible again.
+ const rise = (seconds: number) =>
+ ({ '--hero-delay': `${seconds}s` }) as React.CSSProperties
return (
@@ -206,34 +143,30 @@ export default function HeroSection({
right under the fold copy. */}
{/* LEFT — copy, CTAs, install box */}
-
+
{/* Badge — hairline pill (the one border recipe). */}
- Open source · One core, six frameworks
-
+
{/* Main Heading */}
-
-
+
+ {/* No entrance class here: GradientText's root is a
+ flex block with its own infinite gradient
+ animation, and wrapping it in an animated inline
+ span would drop the transform silently. It
+ simply paints with the server HTML. */}
-
+
{/* Subtitle — tightened; every claim carries over verbatim
(drag-and-drop, headless core, native UI, cloud drives,
camera, screen capture, secure S3 server-mode). */}
-
A drag-and-drop file uploader with a headless core
and native UI for{' '}
@@ -267,16 +203,16 @@ export default function HeroSection({
. Cloud drives, camera, screen capture, and secure
server-mode uploads to any S3-compatible storage.
-
+
{/* CTA Buttons */}
-
+ {/* whileHover/whileTap only — no `initial`, so these
+ never render at opacity 0. */}
@@ -291,10 +227,8 @@ export default function HeroSection({
-
+
{/* Install Command with Package Manager Select — the page's
ONE install surface. Behaviour is unchanged. */}
-
{/* Flat hairline surface — the page's ONE install
surface. No overflow-hidden so the absolute z-50
@@ -321,27 +255,16 @@ export default function HeroSection({
-
+ {/* Plain nodes: these used to fade in
+ from opacity 0 a full second after
+ hydration, which meant the install
+ command itself was invisible in the
+ server HTML. */}
+
{currentCommand}
-
+
-
+
{/* Copy Button */}
-
+
-
-
+
+
{/* 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. */}
+
- );
-};
-
-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. */}
-
+
diff --git a/apps/landing/src/components/DeferredInteractiveExample.tsx b/apps/landing/src/components/DeferredInteractiveExample.tsx
new file mode 100644
index 000000000..2133a127c
--- /dev/null
+++ b/apps/landing/src/components/DeferredInteractiveExample.tsx
@@ -0,0 +1,76 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import dynamic from 'next/dynamic'
+import type { InteractiveExampleProps } from '@useupup/interactive-example'
+
+// The live uploader demo is the single heaviest thing on the marketing pages:
+// `@useupup/interactive-example` drags in @useupup/react, @useupup/core,
+// @mastra/client-js and pako, and all of it used to sit in the initial chunk set
+// of `/` and every `/[framework]/` page even though the section starts below the
+// fold. It carries no indexable copy (the surrounding section headings are
+// server-rendered), so it loads client-side only, and only once the visitor is
+// near it.
+const InteractiveExampleClient = dynamic(
+ () =>
+ import('@/components/InteractiveExampleClient').then(
+ m => m.InteractiveExampleClient,
+ ),
+ { ssr: false, loading: () => },
+)
+
+// Reserves the demo's box so mounting it does not shift the page.
+function DemoPlaceholder() {
+ return
+}
+
+/**
+ * Viewport gate around the demo: mount it the first time it comes within 800px
+ * of the viewport, and keep it mounted afterwards. An IntersectionObserver is
+ * used directly rather than framer's `useInView` so this file pulls in no
+ * animation runtime of its own.
+ *
+ * The lead distance is deliberately generous. The mounted demo is much taller
+ * than the reserved placeholder (1564px vs 520px at a 412px viewport), so the
+ * growth has to happen while the section is still off-screen — otherwise every
+ * visitor scrolling toward it would watch the page below jump.
+ */
+export default function DeferredInteractiveExample(
+ props: InteractiveExampleProps,
+) {
+ const ref = useRef(null)
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ if (mounted) return
+ const el = ref.current
+ if (!el) return
+ // No IntersectionObserver (very old browser, or a test shim): render
+ // the demo rather than hide it.
+ if (typeof IntersectionObserver === 'undefined') {
+ setMounted(true)
+ return
+ }
+ const observer = new IntersectionObserver(
+ entries => {
+ if (entries.some(entry => entry.isIntersecting)) {
+ setMounted(true)
+ observer.disconnect()
+ }
+ },
+ { rootMargin: '800px' },
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [mounted])
+
+ return (
+
+ {mounted ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/landing/src/components/FeatureShowcase/index.tsx b/apps/landing/src/components/FeatureShowcase/index.tsx
index 6649276b9..d4bcf8b8c 100644
--- a/apps/landing/src/components/FeatureShowcase/index.tsx
+++ b/apps/landing/src/components/FeatureShowcase/index.tsx
@@ -1,7 +1,8 @@
'use client'
-import React, { useRef } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import { useInView } from 'framer-motion'
+import dynamic from 'next/dynamic'
import {
FaUpload,
FaGlobe,
@@ -19,14 +20,36 @@ import {
import Card from '@/components/ui/Card'
import { H3_HEADING } from '@/components/ui/SectionHeading'
import { ICON_CHIP } from '@/components/ui/recipes'
-import {
- FrameworksScene,
- DriveScene,
- EditorScene,
- ResumeScene,
- PipelineScene,
-} from '@/components/UploaderScene'
-import { ServerModeVignette } from './vignettes'
+
+// The row visuals are decorative (their column is aria-hidden) and expensive:
+// five UploaderScene mocks plus a diagram, each with its own framer timeline,
+// react-icons and tags that React hoists into document-head image
+// preloads. The row TEXT is indexable and stays server-rendered; only the
+// visuals load client-side, and only once their row is near the viewport.
+const FrameworksScene = dynamic(
+ () => import('@/components/UploaderScene/FrameworksScene'),
+ { ssr: false },
+)
+const DriveScene = dynamic(
+ () => import('@/components/UploaderScene/DriveScene'),
+ { ssr: false },
+)
+const EditorScene = dynamic(
+ () => import('@/components/UploaderScene/EditorScene'),
+ { ssr: false },
+)
+const ResumeScene = dynamic(
+ () => import('@/components/UploaderScene/ResumeScene'),
+ { ssr: false },
+)
+const PipelineScene = dynamic(
+ () => import('@/components/UploaderScene/PipelineScene'),
+ { ssr: false },
+)
+const ServerModeVignette = dynamic(
+ () => import('./vignettes').then(m => m.ServerModeVignette),
+ { ssr: false },
+)
interface HeroRow {
icon: React.ReactNode
@@ -130,6 +153,17 @@ function FeatureRow({ row, index }: { row: HeroRow; index: number }) {
// Non-`once` viewport gate so scenes only animate while on-screen (perf,
// not an entrance animation — rows themselves render static).
const active = useInView(ref, { amount: 0.2 })
+ // A SECOND, deliberately earlier observer decides when the visual mounts:
+ // a scene renders taller than the card's reserved min-height, so mounting
+ // it on `active` (20% visible) would resize a card the visitor is already
+ // looking at. At 600px of lead the growth happens off-screen — no visible
+ // shift, and the scene is ready by the time the row scrolls in. `once`
+ // keeps it mounted, so scrolling back never re-downloads the chunk.
+ const near = useInView(ref, { once: true, margin: '600px' })
+ const [visualMounted, setVisualMounted] = useState(false)
+ useEffect(() => {
+ if (near) setVisualMounted(true)
+ }, [near])
const flipped = index % 2 === 1
const { Visual } = row
@@ -166,8 +200,11 @@ function FeatureRow({ row, index }: { row: HeroRow; index: number }) {
aria-hidden="true"
className={`order-1 ${flipped ? 'lg:order-1' : 'lg:order-2'}`}
>
+ {/* min-h keeps the card's box reserved while the visual is
+ still deferred; the early mount above is what keeps the
+ resize itself off-screen. */}
-
+ {visualMounted && }
diff --git a/apps/landing/src/components/StackBlitzDemoSection/index.tsx b/apps/landing/src/components/StackBlitzDemoSection/index.tsx
index e02832505..5cf80b11b 100644
--- a/apps/landing/src/components/StackBlitzDemoSection/index.tsx
+++ b/apps/landing/src/components/StackBlitzDemoSection/index.tsx
@@ -2,11 +2,13 @@
'use client'
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
-import { motion, AnimatePresence } from 'framer-motion'
+import { motion, AnimatePresence, useInView } from 'framer-motion'
import { Code, ExternalLink, Maximize2, Minimize2 } from 'lucide-react'
import { FaExclamationTriangle } from 'react-icons/fa'
import { SiStackblitz } from 'react-icons/si'
-import sdk, { type Project } from '@stackblitz/sdk'
+// Type-only: the SDK itself (~4.2 MB of staticblitz client + monaco once the
+// embed runs) is imported dynamically, so it never reaches the initial bundle.
+import { type Project } from '@stackblitz/sdk'
import Link from 'next/link'
import Section from '@/components/ui/Section'
import SectionHeading, { GRADIENT_TEXT } from '@/components/ui/SectionHeading'
@@ -159,9 +161,19 @@ function EditorLoadingOverlay() {
)
}
+// One module-level loader so the SDK is fetched at most once per page, whether
+// the embed effect or the "Open in StackBlitz" button asks for it first.
+const loadStackBlitzSdk = () => import('@stackblitz/sdk').then(m => m.default)
+
export default function StackBlitzDemoSection() {
const containerRef = useRef(null)
+ const gateRef = useRef(null)
const cancelButtonRef = useRef(null)
+ // The embed used to run on mount, on every homepage and framework-page
+ // load, pulling megabytes of third-party JS for a section most visitors
+ // never scroll to. It now waits until the editor card is within ~300px of
+ // the viewport; `once` keeps it embedded from then on.
+ const nearViewport = useInView(gateRef, { once: true, margin: '300px' })
const warningTitleId = useId()
const [isFullscreen, setIsFullscreen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
@@ -170,10 +182,12 @@ export default function StackBlitzDemoSection() {
const [pendingFullscreenState, setPendingFullscreenState] = useState(false)
const openInStackBlitz = () => {
- sdk.openProject(stackblitzProject, {
- openFile: OPEN_FILE,
- newWindow: true,
- })
+ void loadStackBlitzSdk().then(sdk =>
+ sdk.openProject(stackblitzProject, {
+ openFile: OPEN_FILE,
+ newWindow: true,
+ }),
+ )
}
// lock body scroll while full screen is active
@@ -190,11 +204,15 @@ export default function StackBlitzDemoSection() {
}
}, [isFullscreen])
- // embed once, on mount and handle fullscreen transitions
+ // Embed once the section is near the viewport, and re-embed across
+ // fullscreen transitions.
useEffect(() => {
+ if (!nearViewport) return
+
// Fallback timer for the pathological case where embedProject never
// resolves; declared in effect scope so the cleanup below can clear it.
let fallbackTimer: ReturnType | undefined
+ let cancelled = false
// Add a small delay to ensure DOM is ready, especially for fullscreen container
const timeoutId = setTimeout(
@@ -214,63 +232,67 @@ export default function StackBlitzDemoSection() {
setIsLoading(true)
setEmbedFailed(false)
- try {
- // Editor-only view: WebContainer preview needs the page
- // to be cross-origin isolated (COOP/COEP), which we do
- // NOT set globally because it breaks the drive OAuth
- // popups in the live demo. So the embed shows the real
- // (credible) source; "Open in StackBlitz" runs it live
- // on stackblitz.com, which is isolated.
- // The SDK replaces targetContainer with its iframe, so
- // hold the parent to find the frame afterwards.
- const embedParent = targetContainer.parentElement
- sdk.embedProject(targetContainer, stackblitzProject, {
- openFile: OPEN_FILE,
- view: 'editor',
- theme: 'dark',
- hideNavigation: true,
- hideDevTools: true,
+ // Editor-only view: WebContainer preview needs the page
+ // to be cross-origin isolated (COOP/COEP), which we do
+ // NOT set globally because it breaks the drive OAuth
+ // popups in the live demo. So the embed shows the real
+ // (credible) source; "Open in StackBlitz" runs it live
+ // on stackblitz.com, which is isolated.
+ // The SDK replaces targetContainer with its iframe, so
+ // hold the parent to find the frame afterwards.
+ const embedParent = targetContainer.parentElement
+ void loadStackBlitzSdk()
+ .then(sdk => {
+ if (cancelled) return
+ return (
+ sdk
+ .embedProject(
+ targetContainer,
+ stackblitzProject,
+ {
+ openFile: OPEN_FILE,
+ view: 'editor',
+ theme: 'dark',
+ hideNavigation: true,
+ hideDevTools: true,
+ },
+ )
+ // Dismiss the loader when the editor is
+ // actually ready, not on a fixed timer.
+ .then(() => {
+ // The SDK's iframe ships without a title;
+ // screen readers need one.
+ const frame =
+ embedParent?.querySelector(
+ 'iframe:not([title])',
+ )
+ frame?.setAttribute(
+ 'title',
+ 'StackBlitz code editor — upup React example',
+ )
+ setIsLoading(false)
+ })
+ )
})
- // Dismiss the loader when the editor is actually
- // ready, not on a fixed timer.
- .then(() => {
- // The SDK's iframe ships without a title;
- // screen readers need one.
- const frame = embedParent?.querySelector(
- 'iframe:not([title])',
- )
- frame?.setAttribute(
- 'title',
- 'StackBlitz code editor — upup React example',
- )
- setIsLoading(false)
- })
- .catch(error => {
- console.error('StackBlitz embed failed:', error)
- setIsLoading(false)
- setEmbedFailed(true)
- })
-
- // Safety net: never leave the loader up indefinitely.
- fallbackTimer = setTimeout(
- () => setIsLoading(false),
- 15000,
- )
- } catch (error) {
- console.error('StackBlitz embed error:', error)
- setIsLoading(false)
- setEmbedFailed(true)
- }
+ .catch(error => {
+ console.error('StackBlitz embed failed:', error)
+ setIsLoading(false)
+ setEmbedFailed(true)
+ })
+
+ // Safety net: never leave the loader up indefinitely.
+ fallbackTimer = setTimeout(() => setIsLoading(false), 15000)
}
},
isFullscreen ? 100 : 0,
) // Delay for fullscreen to ensure DOM is ready
return () => {
+ cancelled = true
clearTimeout(timeoutId)
if (fallbackTimer) clearTimeout(fallbackTimer)
}
- }, [isFullscreen])
+ }, [isFullscreen, nearViewport])
const toggleFullScreen = () => {
const newState = !isFullscreen
@@ -323,7 +345,10 @@ export default function StackBlitzDemoSection() {
{/* Regular container when not fullscreen */}
{!isFullscreen && (
-
+
{
- const url = (event as CustomEvent).detail || window.location.pathname
+ const url =
+ (event as CustomEvent).detail ||
+ window.location.pathname
gtag.pageView(url)
}
@@ -25,11 +27,16 @@ export function Providers({ children }: { children: ReactNode }) {
<>
{gtag.GA_TRACKING_ID && (
<>
+ {/* lazyOnload on BOTH halves — the loader and its config
+ script must share a strategy, or the config can run
+ before gtag.js exists. Analytics is never on the
+ critical path; afterInteractive put ~90 KB of
+ third-party JS in front of hydration on mobile. */}
-