From f3a33dd4beb3f0b796d4189fdc5b3a519795d649 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Thu, 16 Jul 2026 16:15:44 +0200 Subject: [PATCH 1/9] docs: add Designing loading UI guide Add a guide for designing loading states: choosing between a fallback and a pending indicator, sizing skeletons to keep CLS at zero, holding previous results during updates with transitions, revealing variable-height sections top-down with nested Suspense, and crossfading reveals with ViewTransition. Cross-links the profiling guide for finding boundaries that gate too much, and adds CLS cross-links to the interactive apps guide. --- docs/01-app/02-guides/interactive-apps.mdx | 3 +- docs/01-app/02-guides/loading-ui.mdx | 328 +++++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 docs/01-app/02-guides/loading-ui.mdx diff --git a/docs/01-app/02-guides/interactive-apps.mdx b/docs/01-app/02-guides/interactive-apps.mdx index 6f4a8966ea9e..d60f8801ee65 100644 --- a/docs/01-app/02-guides/interactive-apps.mdx +++ b/docs/01-app/02-guides/interactive-apps.mdx @@ -15,7 +15,7 @@ When a user interaction requires server-side work, the result is not available i This guide adds responsive feedback to a task management app called _Taskboard_. Each step handles a different kind of pending work, from streaming slow reads to confirming mutations before the server responds. -> **Good to know:** The patterns in this guide also improve [Core Web Vitals](https://web.dev/articles/vitals). Streaming with [``](https://react.dev/reference/react/Suspense) lowers [FCP](https://web.dev/articles/fcp) and [LCP](https://web.dev/articles/lcp) by letting the shell paint while slow reads finish. Optimistic UI and transitions lower [INP](https://web.dev/articles/inp) by keeping the click frame fast. Prefetching the next route makes FCP, LCP, and INP on the destination page near-instant. These patterns help, but they don't replace the usual INP work: shipping less client JavaScript and avoiding blocking roundtrips during interactions. +> **Good to know:** The patterns in this guide also improve [Core Web Vitals](https://web.dev/articles/vitals). Streaming with [``](https://react.dev/reference/react/Suspense) lowers [FCP](https://web.dev/articles/fcp) and [LCP](https://web.dev/articles/lcp) by letting the shell paint while slow reads finish. Optimistic UI and transitions lower [INP](https://web.dev/articles/inp) by keeping the click frame fast. Prefetching the next route makes FCP, LCP, and INP on the destination page near-instant. These patterns help, but they don't replace the usual INP work: shipping less client JavaScript and avoiding blocking roundtrips during interactions. For [CLS](https://web.dev/articles/cls), keeping placeholders and streamed content from shifting the layout as it loads, see [Designing loading UI](/docs/app/guides/loading-ui). ## Example @@ -776,4 +776,5 @@ Related guides: - [Streaming](/docs/app/guides/streaming) for loading boundaries and `` patterns - [Instant Navigation](/docs/app/guides/instant-navigation) for validating that navigations stay instant +- [Designing loading UI](/docs/app/guides/loading-ui) for fallbacks, pending indicators, and keeping loading states from flashing or shifting - [View Transitions](/docs/app/guides/view-transitions) for animating state changes diff --git a/docs/01-app/02-guides/loading-ui.mdx b/docs/01-app/02-guides/loading-ui.mdx new file mode 100644 index 000000000000..9510e30c457e --- /dev/null +++ b/docs/01-app/02-guides/loading-ui.mdx @@ -0,0 +1,328 @@ +--- +title: Designing loading UI +description: Design loading states with skeleton screens, spinners, and loading.tsx so they don't flash, jitter, or shift layout. +nav_title: Loading UI +related: + description: Learn more about the APIs used in this guide. + links: + - app/api-reference/file-conventions/loading + - app/api-reference/functions/use-link-status + - app/api-reference/config/next-config-js/viewTransition +--- + +While part of a page loads, the user sees a loading state, such as a skeleton or a spinner. A careless one makes the wait worse. It can flash for a response that was already fast, jump when content arrives at a different size, or replace results the user was reading. + +This guide shows how to design loading states that avoid these problems, from choosing the right placeholder to animating the swap to real content. + +> **Good to know:** Loading states affect [Core Web Vitals](https://web.dev/articles/vitals). Sized placeholders and top-down reveals keep [CLS](https://web.dev/articles/cls) at zero, and delayed indicators keep loading states from flashing. The other vitals come from streaming, optimistic UI, and prefetching, covered in [Building interactive apps](/docs/app/guides/interactive-apps). + +## Example + +This guide builds the loading states for a team directory, a searchable member list where each member links to a profile page. The steps build in order: choose the loading state for the member list, size its skeleton so nothing shifts, keep results visible while searching, reveal the profile's variable-height sections top-down, and crossfade the reveals. + +The companion [team directory demo](https://loading-ui-demo.labs.vercel.dev) ([source](https://github.com/vercel-labs/loading-ui-demo)) shows each pattern in action. + +### Step 1: Choose between a fallback and a pending indicator + +A [``](https://react.dev/reference/react/Suspense) fallback replaces content while it loads. A pending indicator keeps the previous UI on screen and signals activity on top of it. + +Use a fallback when there is nothing meaningful on screen yet and the wait is longer than about 200ms. React keeps a shown fallback visible briefly, so a boundary around fast content makes the page slower than no boundary at all. For fast updates, use a pending indicator instead. + +The directory page renders a fast header immediately and reserves the fallback for the slow member list: + +```tsx filename="app/page.tsx" +import { Suspense } from 'react' +import { Stats } from './stats' +import { Directory } from './directory' +import { DirectorySkeleton } from './skeletons' + +export default function Page() { + return ( +
+
+

Team directory

+ {/* Fast, synchronous: never behind a fallback. */} + +
+ + }> + + +
+ ) +} +``` + +The header paints immediately, the skeleton stands in for the list, and the list streams in when its data resolves. + +[`loading.tsx`](/docs/app/api-reference/file-conventions/loading) wraps the whole route in one boundary, which fits a page whose entire first render waits on data. When only part of the page is slow, place `` around that part instead. + +> **Good to know:** A boundary is for the initial load of a region. Retriggering it on fast client navigations (changing its `key`, or adding a `loading.tsx` to a route that renders in milliseconds) replaces a page that would have appeared instantly with a fallback. + +#### Skeleton or spinner + +- **Skeleton**: Use when you know the shape, like a list of rows or a card. It previews the layout and doubles as the reserved space that prevents layout shift (Step 2). A slow, subtle animation, a pulse or a shimmer sweep, signals the page is still working, where a static skeleton can read as frozen (Step 5). +- **Spinner**: Use when you don't know the shape, or for a small inline action. + +Both fit waits of up to about [ten seconds](https://www.nngroup.com/articles/response-times-3-important-limits/). For an operation you know runs longer, show determinate progress, such as a percent-done bar, since neither a skeleton nor a spinner tells the user how much is left. + +### Step 2: Reserve the content's space + +When a fallback is replaced by content of a different size, the browser reflows the page and everything below it moves. That jump is [Cumulative Layout Shift](/docs/app/guides/streaming#cls-cumulative-layout-shift). + +Measure one real row, mirror its box in the skeleton, and render as many skeleton rows as a full result set. A minimum height on the container reserves the full space before any data arrives: + +```tsx filename="app/skeletons.tsx" +// Same row count and row height as a full result set, so the swap to real +// content shifts nothing. +export function DirectorySkeleton({ rows = 8 }: { rows?: number }) { + return ( +
    + {Array.from({ length: rows }).map((_, i) => ( +
  • + + + + + +
  • + ))} +
+ ) +} +``` + +To verify, reload with the layout-shift-regions overlay enabled in your browser's dev tools. The skeleton occupies the same space the list will, so CLS stays at 0. + +> **Good to know:** The `@next/playwright` [`instant()`](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) helper asserts on the UI that is immediately visible on navigation, so a boundary drifting to the wrong place fails in CI. + +### Step 3: Keep previous results visible while searching + +Content that is already on screen should update inside a [transition](https://react.dev/reference/react/useTransition). Re-rendering it behind a fallback replaces working UI with a skeleton, and results that resolve out of order flicker past. A transition keeps the current content on screen, interrupts the in-flight render when a newer update arrives, and exposes an `isPending` flag for the feedback you add yourself. In the directory's search, that feedback is a dim on the held results and a spinner: + +```tsx filename="app/search.tsx" +'use client' + +import { useTransition } from 'react' +import { useRouter } from 'next/navigation' + +export function Search({ children }: { children: React.ReactNode }) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + + function onSearch(value: string) { + startTransition(() => { + // `scroll: false` keeps focus in the input during the navigation. + router.replace(value ? `/?q=${encodeURIComponent(value)}` : '/', { + scroll: false, + }) + }) + } + + return ( +
+ onSearch(e.target.value)} + aria-label="Search members" + /> + +
+ {children} +
+
+ ) +} +``` + +`isPending` turns true the moment the transition starts, even when the response arrives within milliseconds, so a spinner bound to it directly would flash on every fast search. Start it invisible and reveal it after a delay: + +```css filename="app/globals.css" +/* Invisible for 150ms, so a fast search resolves before it is ever seen. */ +.pending { + opacity: 0; +} + +.pending[data-active] { + animation: + pending-in 0.2s cubic-bezier(0.23, 1, 0.32, 1) 0.15s forwards, + spin 0.7s linear infinite; +} + +@keyframes pending-in { + to { + opacity: 1; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} +``` + +Typing now keeps the last complete result set on screen, dimmed, and updates once to the final matches. A search that resolves in under 150ms never shows the spinner. Keep the reveal under 300ms and avoid `ease-in`, which starts slow and holds the finished content back. + +Give the results a single `` boundary so its skeleton shows on the first load only, and don't retrigger it per search. If a query per keystroke is too chatty for your server, add a short debounce before the navigation. The debounce delays the transition's `isPending`, so derive the pending flag by comparing the input value to the URL instead. + +The same applies to navigations. When the destination has its own `` shell, the click lands on that shell instantly, and its skeletons are the feedback. For links whose destination has no shell, [`useLinkStatus`](/docs/app/api-reference/functions/use-link-status#gracefully-handling-fast-navigation) exposes the link's `pending` state to drive the same delayed indicator. + +This is the same pattern as [filter with pending feedback](/docs/app/guides/interactive-apps#step-3-filter-with-pending-feedback) in the interactive apps guide. + +### Step 4: Reveal variable-height sections top-down + +Sizing the skeleton works for a list of fixed rows. On the profile page, the bio can be one paragraph or three and the activity feed two rows or eight, so no skeleton can reserve the right height. + +With sibling `` boundaries, the sections reveal in whatever order their data arrives. If the feed resolves before the bio above it, the feed paints high on the page, then gets pushed down when the bio arrives. Nesting the feed's boundary inside the bio's fixes this, because a nested boundary can't reveal before its parent. The sections appear top-down, and a long bio grows into skeleton space below it. + +```tsx filename="app/member/[id]/page.tsx" +import { Suspense } from 'react' +import { Bio, Activity } from './profile' +import { BioSkeleton, ActivitySkeleton } from './skeletons' + +export default function MemberPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + return ( +
+ }> + + }> + + + +
+ ) +} +``` + +Both queries still run in parallel, so the page settles as fast as its slowest section. Nesting only delays the reveal of a finished section, not its query. + +That hold is the trade-off, since a fast feed now waits for a slow bio. Use sibling boundaries for sections that are visually independent, like dashboard cards in a grid, or a single boundary around all the sections when per-section reveals aren't worth it. The [profiling guide](/docs/app/guides/profiling-performance#step-2-find-content-gated-by-one-boundary) shows how to find a boundary that gates too much content. + +### Step 5: Crossfade the reveal + +A skeleton that hard-cuts to content replaces it in a single frame. React's [``](https://react.dev/reference/react/ViewTransition) component animates the swap as a crossfade instead. Enable it in your Next.js config: + +```ts filename="next.config.ts" +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + experimental: { + viewTransition: true, + }, +} + +export default nextConfig +``` + +Then wrap the content that streams in. A bare `` uses the browser's built-in crossfade, so content that reveals once, like the profile sections from Step 4, needs no view-transition CSS: + +```tsx filename="app/member/[id]/page.tsx" highlight={2,5} +}> + + + }> + + + + + + +``` + +> **Good to know:** The reveal animates whenever the skeleton is replaced by streamed content, on a refresh as much as after a navigation. Content fast enough to arrive in the initial response has no skeleton, so there is nothing to reveal. + +#### Refine and reuse + +Wrap the fallback in a `` so the outgoing skeleton fades too. For content a navigation re-renders, like search results on every keystroke, add `default="none"` so nothing animates by default and `enter="auto"` so only the first reveal does: + +```tsx filename="app/page.tsx" highlight={3,8} + + + + } +> + + + +
+``` + +Once the same props repeat across reveals, extract a component. The demo wraps every reveal in a single ``: + +```tsx filename="app/crossfade.tsx" +import { ViewTransition } from 'react' + +export function Crossfade({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} +``` + +See [Designing view transitions](/docs/app/guides/view-transitions) for the full API, including directional and shared-element transitions. + +#### Keep reveal animations cheap + +- **Animate reveals only**: Content in the static shell is on screen from the first paint, so there is nothing to reveal and nothing to wrap. +- **Guard re-rendered content with `default="none"`**: A `` runs its exit/enter pair on every update, so re-rendered content would re-animate on every navigation. +- **Animate `opacity` and `transform` only**: Both run on the compositor, off the main thread. A loading animation plays while the page streams and hydrates, exactly when the main thread is busiest, so a property that repaints every frame, like `filter: blur()` or `background-position`, stutters right when the animation is visible. A pulse animates `opacity`, and a shimmer sweep should translate a gradient overlay with `transform`. + +```css filename="app/globals.css" +/* Opacity only, so the pulse runs on the compositor. Slow and shallow. */ +.skeleton { + background: rgba(150, 150, 150, 0.12); + animation: skeleton-pulse 1.8s ease-in-out infinite; +} + +@keyframes skeleton-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} +``` + +Respect reduced motion preferences by disabling the animations: + +```css filename="app/globals.css" +@media (prefers-reduced-motion: reduce) { + .pending[data-active], + .skeleton { + animation-duration: 0.01ms; + animation-iteration-count: 1; + } + + ::view-transition-old(*), + ::view-transition-new(*), + ::view-transition-group(*) { + animation-duration: 0s !important; + animation-delay: 0s !important; + } +} +``` + +## Next steps + +Related guides: + +- [Streaming](/docs/app/guides/streaming) for `loading.js` versus ``, the static shell, and Web Vitals +- [Instant navigation](/docs/app/guides/instant-navigation) for boundary placement, the Navigation Inspector, and `instant()` regression tests +- [View transitions](/docs/app/guides/view-transitions) for the full `` API +- [Building interactive apps](/docs/app/guides/interactive-apps) for pending feedback and optimistic UI with transitions +- [Profiling performance](/docs/app/guides/profiling-performance) for finding slow Suspense boundaries and data waterfalls in the Performance panel +- [Forms](/docs/app/guides/forms) for `useFormStatus` pending state during submissions From 9964bc2dfce5819c89f08bec3cbb8cd711d39034 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Thu, 16 Jul 2026 21:17:37 +0200 Subject: [PATCH 2/9] docs: use divs for the aria-hidden directory skeleton An aria-hidden skeleton gains nothing from ul/li semantics (screen readers skip the subtree); the geometry that prevents layout shift comes from the classes. Switch to plain divs and note why. --- docs/01-app/02-guides/loading-ui.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/01-app/02-guides/loading-ui.mdx b/docs/01-app/02-guides/loading-ui.mdx index 9510e30c457e..5b8bd316aeb0 100644 --- a/docs/01-app/02-guides/loading-ui.mdx +++ b/docs/01-app/02-guides/loading-ui.mdx @@ -77,21 +77,23 @@ Measure one real row, mirror its box in the skeleton, and render as many skeleto // content shifts nothing. export function DirectorySkeleton({ rows = 8 }: { rows?: number }) { return ( -
    +
    {Array.from({ length: rows }).map((_, i) => ( -
  • +
    -
  • +
    ))} -
+ ) } ``` +The skeleton is a decorative placeholder, so it's `aria-hidden` and built from plain `
`s: screen readers skip it, and only its box dimensions need to match the real rows, which they do through the same classes. + To verify, reload with the layout-shift-regions overlay enabled in your browser's dev tools. The skeleton occupies the same space the list will, so CLS stays at 0. > **Good to know:** The `@next/playwright` [`instant()`](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) helper asserts on the UI that is immediately visible on navigation, so a boundary drifting to the wrong place fails in CI. From 6a2f411668c8d50bc2480414bc8d4f08da49dff7 Mon Sep 17 00:00:00 2001 From: Aurora Scharff Date: Sat, 18 Jul 2026 21:12:49 +0200 Subject: [PATCH 3/9] add more patterns and refine demo --- docs/01-app/02-guides/interactive-apps.mdx | 2 + docs/01-app/02-guides/loading-ui.mdx | 174 +++++++++++++++++++-- 2 files changed, 161 insertions(+), 15 deletions(-) diff --git a/docs/01-app/02-guides/interactive-apps.mdx b/docs/01-app/02-guides/interactive-apps.mdx index d60f8801ee65..791af977702b 100644 --- a/docs/01-app/02-guides/interactive-apps.mdx +++ b/docs/01-app/02-guides/interactive-apps.mdx @@ -15,6 +15,8 @@ When a user interaction requires server-side work, the result is not available i This guide adds responsive feedback to a task management app called _Taskboard_. Each step handles a different kind of pending work, from streaming slow reads to confirming mutations before the server responds. +For the loading reveals themselves, such as sizing skeletons, choosing Suspense boundary granularity, and animating the swap to real content, see [Designing loading UI](/docs/app/guides/loading-ui). This guide covers how interactions feel, and that one covers how loading looks. + > **Good to know:** The patterns in this guide also improve [Core Web Vitals](https://web.dev/articles/vitals). Streaming with [``](https://react.dev/reference/react/Suspense) lowers [FCP](https://web.dev/articles/fcp) and [LCP](https://web.dev/articles/lcp) by letting the shell paint while slow reads finish. Optimistic UI and transitions lower [INP](https://web.dev/articles/inp) by keeping the click frame fast. Prefetching the next route makes FCP, LCP, and INP on the destination page near-instant. These patterns help, but they don't replace the usual INP work: shipping less client JavaScript and avoiding blocking roundtrips during interactions. For [CLS](https://web.dev/articles/cls), keeping placeholders and streamed content from shifting the layout as it loads, see [Designing loading UI](/docs/app/guides/loading-ui). ## Example diff --git a/docs/01-app/02-guides/loading-ui.mdx b/docs/01-app/02-guides/loading-ui.mdx index 5b8bd316aeb0..b362e4452642 100644 --- a/docs/01-app/02-guides/loading-ui.mdx +++ b/docs/01-app/02-guides/loading-ui.mdx @@ -14,11 +14,13 @@ While part of a page loads, the user sees a loading state, such as a skeleton or This guide shows how to design loading states that avoid these problems, from choosing the right placeholder to animating the swap to real content. +For the interactions these loading states respond to, such as transitions and optimistic UI, see [Building interactive apps](/docs/app/guides/interactive-apps). + > **Good to know:** Loading states affect [Core Web Vitals](https://web.dev/articles/vitals). Sized placeholders and top-down reveals keep [CLS](https://web.dev/articles/cls) at zero, and delayed indicators keep loading states from flashing. The other vitals come from streaming, optimistic UI, and prefetching, covered in [Building interactive apps](/docs/app/guides/interactive-apps). ## Example -This guide builds the loading states for a team directory, a searchable member list where each member links to a profile page. The steps build in order: choose the loading state for the member list, size its skeleton so nothing shifts, keep results visible while searching, reveal the profile's variable-height sections top-down, and crossfade the reveals. +This guide builds the loading states for a team directory, a searchable member list where each member links to a profile page. The steps build in order: choose the loading state for the member list, size its skeleton so nothing shifts, keep results visible while searching, reveal the profile's variable-height sections top-down, crossfade the reveals, and load more members without shifting the page. The companion [team directory demo](https://loading-ui-demo.labs.vercel.dev) ([source](https://github.com/vercel-labs/loading-ui-demo)) shows each pattern in action. @@ -94,6 +96,8 @@ export function DirectorySkeleton({ rows = 8 }: { rows?: number }) { The skeleton is a decorative placeholder, so it's `aria-hidden` and built from plain `
`s: screen readers skip it, and only its box dimensions need to match the real rows, which they do through the same classes. +If you build the skeleton from the component's own sub-skeletons, like an avatar or a text line, and keep it beside the component, the two stay in sync as the component changes. + To verify, reload with the layout-shift-regions overlay enabled in your browser's dev tools. The skeleton occupies the same space the list will, so CLS stays at 0. > **Good to know:** The `@next/playwright` [`instant()`](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) helper asserts on the UI that is immediately visible on navigation, so a boundary drifting to the wrong place fails in CI. @@ -194,10 +198,14 @@ export default function MemberPage({ return (
}> - - }> - - + {params.then(({ id }) => ( + <> + + }> + + + + ))}
) @@ -206,7 +214,9 @@ export default function MemberPage({ Both queries still run in parallel, so the page settles as fast as its slowest section. Nesting only delays the reveal of a finished section, not its query. -That hold is the trade-off, since a fast feed now waits for a slow bio. Use sibling boundaries for sections that are visually independent, like dashboard cards in a grid, or a single boundary around all the sections when per-section reveals aren't worth it. The [profiling guide](/docs/app/guides/profiling-performance#step-2-find-content-gated-by-one-boundary) shows how to find a boundary that gates too much content. +> **Good to know:** The [profiling guide](/docs/app/guides/profiling-performance#step-2-find-content-gated-by-one-boundary) shows these sections streaming in the Performance panel, so you can confirm they run in parallel and find a boundary that gates too much content. + +That hold is the trade-off, since a fast feed now waits for a slow bio. Use sibling boundaries for sections that are visually independent, like dashboard cards in a grid, or a single boundary around all the sections when per-section reveals aren't worth it. Where a section can fail on its own, it's common to give it an [error boundary](/docs/app/getting-started/error-handling) too, though not every section needs one. ### Step 5: Crossfade the reveal @@ -226,16 +236,18 @@ export default nextConfig Then wrap the content that streams in. A bare `` uses the browser's built-in crossfade, so content that reveals once, like the profile sections from Step 4, needs no view-transition CSS: -```tsx filename="app/member/[id]/page.tsx" highlight={2,5} +```tsx filename="app/member/[id]/page.tsx" highlight={3,6} }> - - - }> - - - - - + {params.then(({ id }) => ( + + + }> + + + + + + ))} ``` @@ -318,6 +330,138 @@ Respect reduced motion preferences by disabling the animations: } ``` +### Step 6: Load the next page without a shift + +A long list loads its first page, then grows on demand. Done carelessly, "load more" remounts or reloads the rows already on screen, so they flash or the page jumps. Drive the page count from the URL and give each page its own boundary, so a new page appends below the existing ones without touching them. + +Keep the page component synchronous and resolve the count inline with `searchParams.then()`, passing the value to the list. Awaiting `searchParams` in the page itself opts the whole page out of the static shell under `cacheComponents`: + +```tsx filename="app/page.tsx" +import { Suspense } from 'react' +import { MemberPages } from './member-list' +import { DirectorySkeleton } from './skeletons' + +export default function Page({ + searchParams, +}: { + searchParams: Promise<{ page?: string }> +}) { + return ( +
+
+

Team directory

+
+ + }> + {searchParams.then(({ page }) => ( + + ))} + +
+ ) +} +``` + +`MemberPages` reads the count and renders every page into one shared `