Skip to content

Pages

Create a React component in the pages directory, where the name of the file is the path a user will navigate to in the url.

In general, try to keep page logic simple. Pull any complex logic out into a component in the components directory in a folder with the same name as the route.

Page transitions

Navigations animate with React's <ViewTransition>. Every page sits in a keyed sheet in PageWrapper, which fades out and in when its key changes. The key defaults to the URL path, so query changes update a page in place.

  • A page that should animate on query changes sets a static transitionKey, such as Search.transitionKey = queryTransitionKey('q', 'variant').
  • Paginated lists wrap only their results in <PageTurn page={page}>, and their Previous/Next controls navigate with pushWithTransition(router, url, pageTurn(page, page + 1)) (or page - 1). The results turn like a book's page: going forward, their right half turns over onto the left one, with the next page's left half printed on its back; going back, the left half turns over to the right. The rest of the page stays put, and the turn starts from the top of the page, where Next would scroll anyway. Never return or await router.push inside startTransition: it resolves only after the new page commits, so the transition would wait on itself forever.
  • The turning sheet needs half of each page while the flat halves show the other ones, but a view transition captures each element once. So while a page turns, PageTurn keeps a copy of its page's DOM under itself as the page-turn-sheet snapshot: rendering the page again would cost as much as the navigation itself, right before the turn starts. The copy follows the page being left until the navigation commits, then the new one while its effects fill it in. The view transition captures the old page as it is when React starts it, so NavigationTransitionTypes commits the copy with flushSync right before Next renders the navigation, and PageTurn removes it once the transition has finished.
  • The sheet stays flat: a snapshot only turns as one plane, and bending it would take a strip per facet, each one more print of both pages. Shading toward its lifted edge makes it look curved instead.
  • A list paginated in its own state, like the lists of Find My Combos in ComboResults, turns the same way without a navigation: it scrolls to its top, commits its sheet with flushSync(() => showTurningSheet(owner)), then sets its page in a startTransition tagged with pageTurn. Several such lists can share a page, so each passes its useId() as the owner of its PageTurn, and only the matching one prints the sheet: two snapshots with one name would cancel the whole transition.
  • Sorting the results again (RESORT) crossfades them inside their PageTurn, whether the page stays the same or goes back to the first one.
  • /find-my-combos doesn't fade in when it has results in its session cache: it restores them once mounted and scrolls back down to them, which would jump in the middle of the fade.
  • The home page's logo and the header's gear share the site-logo name, so the logo shrinks into the gear as the search bar docks.
  • React only names a <ViewTransition> whose elements are in the viewport, so a shared element that is scrolled out of view doesn't fly: the transition goes on without it.

Other transitions are tagged by their components with addTransitionType inside startTransition: Tab slides its panels across like a carousel, in the order of its tabs, and glides its underline over (TAB_FORWARD, TAB_BACK): its panel updates in place rather than being replaced, so the old and the new one share an image pair clipped to the panel, and a template card grows its wheel's card into its place in the replacement list and back (TEMPLATE_MORPH), through a <ViewTransition name share> that only one of them renders at a time. Load More slides the last row of a replacement list aside as its new cards rise in (LOAD_MORE): only the cards of the last page can move, so only they map update to an animation, which keeps the snapshots few, and the new cards rise in with @starting-style rather than as snapshots, so that the dialog's edges still clip them. Its transition starts when the page arrives, but only ever after a click on Load More. The theme switch is no React update, so ThemeSelector calls document.startViewTransition with the THEME_SWITCH type itself, and applies the theme in its update, after the old page is captured.

All motion lives in src/assets/view-transitions.css. _document gives the root an inline view-transition-name, so that file, rather than React, decides what the root snapshot does: it keeps it still. The header is named there too, through its siteHeader class, so that transitions that redraw the whole page, like the theme switch, can leave it in the root snapshot. Menus, the cookie banner and modals open and close with CSS transitions and @starting-style instead, without view transitions, and so does content that arrives on its own time, like the bracket estimate: in Firefox, a click that lands while a view transition is starting can be lost, so only an input should start one. Modal is a native <dialog> in the top layer: popups that portal to the body would sit under it, so they portal into the dialog given by ModalPortalContext when there is one.

Authors: ldeluigi (62.5%), Blade Barringer (18.75%), Luca Deluigi (18.75%)