RoadieRoadie
  • Home
  • Foundations
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeFoundationsComponents
Appearance

Foundations

  • Visual

    • Colors
    • Elevation
    • Iconography
    • Shape
    • Theming
  • Content

    • Date and time
    • Layout
    • Typography
  • Behaviour

    • Accessibility
    • Interactions
    • Motion
    • View transitions
  • Building apps

    • Linking
    • Navigation
    • Performance

Navigation

ReferenceComponent utilities tokens

Navigator draws your app's destinations and lays out its Panes. Each level of the URL adds one pane, and nested layouts render them.

The model

Navigation has four parts, and each is a component you type.

NavigatorThe app frame. You give it the current route.

Navigator.PrimaryThe top-level destinations

  • Tickets
  • Orders
  • Settings

Navigator.SecondaryThe pages inside one destination

  • Profile
  • Payment

PaneTickets

PaneAn event

PaneA ticket

Navigator holds the destinations and the panes. Each URL level adds one pane beside the last.
  • Navigator is the app frame. You give it the current route as value.
  • Navigator.Primary holds the top-level destinations.
  • Navigator.Secondary holds the pages inside one destination.
  • Pane is a column of content.

Navigator.Item is a destination, in either nav.

<Navigator value={pathname}>
<Navigator.Primary aria-label='Account'>
<Navigator.Item value='/account/tickets' href='/account/tickets' icon={<TicketIcon />}>
Tickets
</Navigator.Item>
<Navigator.Item value='/account/settings' href='/account/settings' icon={<GearIcon />}>
Settings
<Navigator.Secondary aria-label='Settings pages'>
<Navigator.Item value='/account/settings/profile' href='/account/settings/profile'>
Profile
</Navigator.Item>
</Navigator.Secondary>
</Navigator.Item>
</Navigator.Primary>
<Pane>
<Pane.Header>
<Pane.Title>Tickets</Pane.Title>
</Pane.Header>
</Pane>
</Navigator>

One URL level is one pane. /account/tickets is one pane, /account/tickets/muster-fest-glamping-4821 is two, and /account/tickets/muster-fest-glamping-4821/tk-9f2c1a is three. Roadie reads each pane's depth from the order the panes render in.

Everything else is automatic or opt-in.

  • Destinations that don't fit fold into More.
  • A destination can open a Navigator.Menu instead of navigating.
  • The secondary nav's list pane is generated. Navigator.SecondaryPane replaces it with your own.

A shell has at most one list pane, any number of detail panes and at most one inspector. column says which one a pane is, and it defaults to detail. A shell with no list is normal. This docs site is a single detail pane.

There is no tertiary level. A secondary nav can't hold another one, and a pane at depth 2 is just a pane.

Build it up

Each example adds one idea to the one before it. DemoRouter is a docs-only router. It keeps the path in state and gives it to the render function, the way a layout gets it from your framework. Press Full width to see the panes as columns, and narrow the window to see the tab bar and the stacked panes.

Destinations

Two destinations and one pane that shows the current page.

const titles = {
  '/account/tickets': 'Tickets',
  '/account/orders': 'Orders'
}

function Destinations() {
  return (
    <div className='h-[30rem] overflow-hidden rounded-2xl border border-subtle'>
      <DemoRouter initialPath='/account/tickets'>
        {(path) => (
          <Navigator value={path} className='h-full'>
            <Navigator.Primary aria-label='Account'>
              <Navigator.Item value='/account/tickets' href='/account/tickets' icon={<TicketIcon />}>
                Tickets
              </Navigator.Item>
              <Navigator.Item value='/account/orders' href='/account/orders' icon={<ShoppingCartIcon />}>
                Orders
              </Navigator.Item>
            </Navigator.Primary>
            <Pane>
              <Pane.Header>
                <Pane.Title>{titles[path]}</Pane.Title>
              </Pane.Header>
              <p className='pb-4 text-subtle'>The page at {path}.</p>
            </Pane>
          </Navigator>
        )}
      </DemoRouter>
    </div>
  )
}
render(<Destinations />)

A secondary nav

Put a Navigator.Secondary inside Settings. Navigator generates its list pane, beside your page where two columns fit and behind it when panes stack. Your code still renders one pane.

const titles = {
  '/account/tickets': 'Tickets',
  '/account/orders': 'Orders',
  '/account/settings': 'Settings',
  '/account/settings/profile': 'Profile',
  '/account/settings/payment': 'Payment',
  '/account/settings/notifications': 'Notifications'
}

function SecondaryNav() {
  return (
    <div className='h-[30rem] overflow-hidden rounded-2xl border border-subtle'>
      <DemoRouter initialPath='/account/settings/profile'>
        {(path) => (
          <Navigator value={path} className='h-full'>
            <Navigator.Primary aria-label='Account'>
              <Navigator.Item value='/account/tickets' href='/account/tickets' icon={<TicketIcon />}>
                Tickets
              </Navigator.Item>
              <Navigator.Item value='/account/orders' href='/account/orders' icon={<ShoppingCartIcon />}>
                Orders
              </Navigator.Item>
              <Navigator.Item value='/account/settings' href='/account/settings' icon={<GearIcon />}>
                Settings
                <Navigator.Secondary aria-label='Settings pages'>
                  <Navigator.Item value='/account/settings/profile' href='/account/settings/profile'>
                    Profile
                  </Navigator.Item>
                  <Navigator.Item value='/account/settings/payment' href='/account/settings/payment'>
                    Payment
                  </Navigator.Item>
                  <Navigator.Item value='/account/settings/notifications' href='/account/settings/notifications'>
                    Notifications
                  </Navigator.Item>
                </Navigator.Secondary>
              </Navigator.Item>
            </Navigator.Primary>
            <Pane>
              <Pane.Header>
                <Pane.Title>{titles[path]}</Pane.Title>
              </Pane.Header>
              <p className='pb-4 text-subtle'>The page at {path}.</p>
            </Pane>
          </Navigator>
        )}
      </DemoRouter>
    </div>
  )
}
render(<SecondaryNav />)

A drill-down

Under Tickets, the path picks the panes. The list is always there, and an event adds a second pane after it. Choose an event, then use Back.

const events = [
  { id: 'muster-fest-glamping-4821', title: 'Muster Fest glamping', when: 'Mon 5 May · Rockhampton' },
  { id: 'midnight-frequency-1377', title: 'Midnight Frequency', when: 'Sat 12 Jul · The Longacre' }
]

const titles = {
  '/account/orders': 'Orders',
  '/account/settings': 'Settings',
  '/account/settings/profile': 'Profile',
  '/account/settings/payment': 'Payment'
}

function PagePane({ path }) {
  return (
    <Pane>
      <Pane.Header>
        <Pane.Title>{titles[path]}</Pane.Title>
      </Pane.Header>
      <p className='pb-4 text-subtle'>The page at {path}.</p>
    </Pane>
  )
}

function TicketsPane({ eventId }) {
  return (
    <Pane column='list'>
      <Pane.Header>
        <Pane.Title>Tickets</Pane.Title>
      </Pane.Header>
      <List>
        {events.map((event) => (
          <List.Item
            key={event.id}
            href={`/account/tickets/${event.id}`}
            title={event.title}
            description={event.when}
            current={event.id === eventId}
            chevron
          />
        ))}
      </List>
    </Pane>
  )
}

function EventPane({ event }) {
  return (
    <Pane>
      <Pane.Header backHref='/account/tickets' backLabel='Tickets'>
        <Pane.Title>{event.title}</Pane.Title>
      </Pane.Header>
      <p className='pb-4 text-subtle'>{event.when}</p>
    </Pane>
  )
}

function DrillDown() {
  return (
    <div className='h-[30rem] overflow-hidden rounded-2xl border border-subtle'>
      <DemoRouter initialPath='/account/tickets'>
        {(path) => {
          const [, , destination, eventId] = path.split('/')
          const event = events.find(({ id }) => id === eventId)
          return (
            <Navigator value={path} className='h-full'>
            <Navigator.Primary aria-label='Account'>
              <Navigator.Item value='/account/tickets' href='/account/tickets' icon={<TicketIcon />}>
                Tickets
              </Navigator.Item>
              <Navigator.Item value='/account/orders' href='/account/orders' icon={<ShoppingCartIcon />}>
                Orders
              </Navigator.Item>
              <Navigator.Item value='/account/settings' href='/account/settings' icon={<GearIcon />}>
                Settings
                <Navigator.Secondary aria-label='Settings pages'>
                  <Navigator.Item value='/account/settings/profile' href='/account/settings/profile'>
                    Profile
                  </Navigator.Item>
                  <Navigator.Item value='/account/settings/payment' href='/account/settings/payment'>
                    Payment
                  </Navigator.Item>
                </Navigator.Secondary>
              </Navigator.Item>
            </Navigator.Primary>
              {destination === 'tickets' ? (
                <>
                  <TicketsPane eventId={eventId} />
                  {event && <EventPane event={event} />}
                </>
              ) : (
                <PagePane path={path} />
              )}
            </Navigator>
          )
        }}
      </DemoRouter>
    </div>
  )
}
render(<DrillDown />)

A three-pane drill-down

A ticket adds a third pane. Nothing declares a depth. Each pane takes the next one because it renders after the last. Choose an event, then a ticket.

const events = [
  {
    id: 'muster-fest-glamping-4821',
    title: 'Muster Fest glamping',
    when: 'Mon 5 May · Rockhampton',
    tickets: [
      { id: 'tk-9f2c1a', title: 'Bell tent for two', detail: 'Site 14 · check in from 2 pm' },
      { id: 'tk-4e81d7', title: 'Breakfast pack', detail: 'Collect from the mess tent' }
    ]
  },
  {
    id: 'midnight-frequency-1377',
    title: 'Midnight Frequency',
    when: 'Sat 12 Jul · The Longacre',
    tickets: [
      { id: 'tk-2c55b0', title: 'General admission', detail: 'Doors 7 pm' }
    ]
  }
]

const titles = {
  '/account/orders': 'Orders',
  '/account/settings': 'Settings',
  '/account/settings/profile': 'Profile',
  '/account/settings/payment': 'Payment'
}

function PagePane({ path }) {
  return (
    <Pane>
      <Pane.Header>
        <Pane.Title>{titles[path]}</Pane.Title>
      </Pane.Header>
      <p className='pb-4 text-subtle'>The page at {path}.</p>
    </Pane>
  )
}

function TicketsPane({ eventId }) {
  return (
    <Pane column='list'>
      <Pane.Header>
        <Pane.Title>Tickets</Pane.Title>
      </Pane.Header>
      <List>
        {events.map((event) => (
          <List.Item
            key={event.id}
            href={`/account/tickets/${event.id}`}
            title={event.title}
            description={event.when}
            current={event.id === eventId}
            chevron
          />
        ))}
      </List>
    </Pane>
  )
}

function EventPane({ event, ticketId }) {
  return (
    <Pane>
      <Pane.Header backHref='/account/tickets' backLabel='Tickets'>
        <Pane.Title>{event.title}</Pane.Title>
      </Pane.Header>
      <List>
        {event.tickets.map((ticket) => (
          <List.Item
            key={ticket.id}
            href={`/account/tickets/${event.id}/${ticket.id}`}
            title={ticket.title}
            description={ticket.detail}
            current={ticket.id === ticketId}
            chevron
          />
        ))}
      </List>
    </Pane>
  )
}

function TicketPane({ event, ticket }) {
  return (
    <Pane>
      <Pane.Header backHref={`/account/tickets/${event.id}`} backLabel={event.title}>
        <Pane.Title>{ticket.title}</Pane.Title>
      </Pane.Header>
      <p className='pb-4 text-subtle'>{ticket.detail}</p>
    </Pane>
  )
}

function ThreePanes() {
  return (
    <div className='h-[30rem] overflow-hidden rounded-2xl border border-subtle'>
      <DemoRouter initialPath='/account/tickets'>
        {(path) => {
          const [, , destination, eventId, ticketId] = path.split('/')
          const event = events.find(({ id }) => id === eventId)
          const ticket = event?.tickets.find(({ id }) => id === ticketId)
          return (
            <Navigator value={path} className='h-full'>
            <Navigator.Primary aria-label='Account'>
              <Navigator.Item value='/account/tickets' href='/account/tickets' icon={<TicketIcon />}>
                Tickets
              </Navigator.Item>
              <Navigator.Item value='/account/orders' href='/account/orders' icon={<ShoppingCartIcon />}>
                Orders
              </Navigator.Item>
              <Navigator.Item value='/account/settings' href='/account/settings' icon={<GearIcon />}>
                Settings
                <Navigator.Secondary aria-label='Settings pages'>
                  <Navigator.Item value='/account/settings/profile' href='/account/settings/profile'>
                    Profile
                  </Navigator.Item>
                  <Navigator.Item value='/account/settings/payment' href='/account/settings/payment'>
                    Payment
                  </Navigator.Item>
                </Navigator.Secondary>
              </Navigator.Item>
            </Navigator.Primary>
              {destination === 'tickets' ? (
                <>
                  <TicketsPane eventId={eventId} />
                  {event && <EventPane event={event} ticketId={ticketId} />}
                  {ticket && <TicketPane event={event} ticket={ticket} />}
                </>
              ) : (
                <PagePane path={path} />
              )}
            </Navigator>
          )
        }}
      </DemoRouter>
    </div>
  )
}
render(<ThreePanes />)

Map it to routes

Use nested layouts. Each route layout renders its own pane, then {children}. A URL renders exactly the panes its depth implies, whether you click to it or reload it.

app/account/
├─ layout.tsx          the shell, with Navigator and its destinations
└─ tickets/
   ├─ layout.tsx       the tickets pane, then {children}
   ├─ page.tsx         null
   └─ [event]/
      ├─ layout.tsx    the event pane, then {children}
      ├─ page.tsx      null
      └─ [ticket]/
         └─ page.tsx   the ticket pane
URLPanes
/account/ticketsTickets
/account/tickets/muster-fest-glamping-4821Tickets, Muster Fest glamping
/account/tickets/muster-fest-glamping-4821/tk-9f2c1aTickets, Muster Fest glamping, Bell tent for two

The shell

The top layout is a client component. It passes usePathname() as value, so the nav and the panes read the same route. Mount RoadieLinkProvider once at the app root, so every href stays a client navigation.

// app/account/layout.tsx
'use client'
import type { ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { Navigator } from '@oztix/roadie-components/navigator'
import { ShoppingCartIcon, TicketIcon } from '@phosphor-icons/react'
export default function AccountLayout({ children }: { children: ReactNode }) {
const pathname = usePathname()
return (
<Navigator value={pathname} className='h-dvh'>
<Navigator.Primary aria-label='Account'>
<Navigator.Item value='/account/tickets' href='/account/tickets' icon={<TicketIcon />}>
Tickets
</Navigator.Item>
<Navigator.Item value='/account/orders' href='/account/orders' icon={<ShoppingCartIcon />}>
Orders
</Navigator.Item>
</Navigator.Primary>
{children}
</Navigator>
)
}

The fragment rule

A layout returns its pane and {children} in a fragment. The fragment adds no element, so every pane lands as a sibling of the others and Roadie reads the depth from their order. Wrap them in a div and the columns break.

// app/account/tickets/TicketsPane.tsx
export function TicketsPane() {
return (
<Pane column='list'>
<Pane.Header>
<Pane.Title>Tickets</Pane.Title>
</Pane.Header>
<TicketList />
</Pane>
)
}
// app/account/tickets/layout.tsx
export default function TicketsLayout({ children }: { children: ReactNode }) {
return (
<>
<TicketsPane />
{children}
</>
)
}
// app/account/tickets/[event]/layout.tsx
export default function EventLayout({ children, params }: EventLayoutProps) {
return (
<>
<EventPane params={params} />
{children}
</>
)
}
// app/account/tickets/[event]/[ticket]/page.tsx
export default function TicketPage({ params }: TicketPageProps) {
return <TicketPane params={params} />
}

The empty page

A level whose pane comes from its layout still needs a page.tsx for the URL to exist. It returns null. This is the cost of nested layouts, one line and one comment per level.

// app/account/tickets/page.tsx
export default function TicketsPage() {
return null // The tickets pane comes from layout.tsx.
}

Server and client

PieceMust beWhy
The shell layout, Navigator, Navigator.Primary and everything inside itClientNavigator finds its parts by element reference. A server component replaces those references, so items, groups and menus silently disappear.
Pane and everything in itServer-safeThis is what lets each route layout stay a plain server component.

Navigator.Primary must be a direct child of Navigator. Your own component around it hides it. See splitting a big tree.

One shell, many rails

An app with several trees, such as /user and /orgs, can share one shell. It takes a kind and a base path. Each rail is a function the shell calls, not a component it renders, so Navigator.Primary stays a direct child. Views take the same base path, so one tickets view serves both trees.

// app/user/[user]/layout.tsx
export default async function UserLayout({ children, params }: UserLayoutProps) {
const { user } = await params
return (
<AppNavigator kind='user' base={`/user/${user}`}>
{children}
</AppNavigator>
)
}
// components/AppNavigator.tsx
'use client'
export function AppNavigator({ kind, base, children }: AppNavigatorProps) {
const pathname = usePathname()
return (
<Navigator value={pathname} className='h-dvh'>
{kind === 'org' ? orgRail(base) : userRail(base)}
{children}
</Navigator>
)
}
function userRail(base: string) {
return (
<Navigator.Primary aria-label='Account'>
<Navigator.Item value={`${base}/tickets`} href={`${base}/tickets`} icon={<TicketIcon />}>
Tickets
</Navigator.Item>
</Navigator.Primary>
)
}

Keep the list and More in the URL

Roadie never reads the URL. To let a phone user open a destination's list, or More, from a link or with Back, keep a flag such as ?nav or ?more in the query string and pass it to showList and showMore. The Navigator reference covers the props.

  • Read the query in a leaf inside its own Suspense boundary. useSearchParams client-renders a prerendered page up to the nearest one.
  • Push rather than replace, so the browser's Back undoes the change.
  • Build the new query from window.location.search, so the page's own params survive the flag.
'use client'
import { Suspense, useEffect, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { Navigator } from '@oztix/roadie-components/navigator'
type NavQuery = { nav: boolean; more: boolean }
function NavQueryFlags({ onChange }: { onChange: (next: NavQuery) => void }) {
const params = useSearchParams()
const nav = params.has('nav')
const more = params.has('more')
useEffect(() => onChange({ nav, more }), [nav, more, onChange])
return null
}
export function AppNavigator() {
const pathname = usePathname()
const router = useRouter()
const [query, setQuery] = useState<NavQuery>({ nav: false, more: false })
const toggle = (param: string) => (next: boolean) => {
const params = new URLSearchParams(window.location.search)
params.delete(param)
const search = [params.toString(), next ? param : '']
.filter(Boolean)
.join('&')
router.push(search ? `${pathname}?${search}` : pathname, { scroll: false })
}
return (
<>
<Suspense fallback={null}>
<NavQueryFlags onChange={setQuery} />
</Suspense>
<Navigator
value={pathname}
showList={query.nav}
onShowListChange={toggle('nav')}
showMore={query.more}
onShowMoreChange={toggle('more')}
>
…
</Navigator>
</>
)
}

When to pass depth

Almost never. Roadie derives depth from render order, on the server too. Pass it only when panes render out of order, as in a resumed partial prerender under Next's cacheComponents, or sibling Suspense boundaries that hydrate out of order.

Loading and waiting

Return the pane straight away and await inside it. The column and the header paint within about 40ms of the click, a skeleton stands in for the body, and the frame shows that it is waiting.

Await inside the pane

export default function TicketPage({ params }: TicketPageProps) {
return (
<Pane>
<Pane.Header>
<Pane.Title>Ticket</Pane.Title>
</Pane.Header>
<Pane.Body loading={<TicketSkeleton />}>
<TicketDetail params={params} />
</Pane.Body>
</Pane>
)
}
async function TicketDetail({ params }: TicketPageProps) {
const ticket = await getTicket((await params).ticket)
return <TicketSummary ticket={ticket} />
}

Do

Put the slow part in its own async component inside Pane.Body. The real header paints at once and the body shows the skeleton.

export default async function TicketPage({ params }: TicketPageProps) {
const ticket = await getTicket((await params).ticket)
return (
<Pane>
<Pane.Header>
<Pane.Title>{ticket.title}</Pane.Title>
</Pane.Header>
<TicketSummary ticket={ticket} />
</Pane>
)
}

Don’t

Await in the page or layout before it returns the pane. Next holds the whole route, and nothing on screen changes until the data arrives.

  • Awaiting params above the pane is fine. It resolves at once. The rule is about slow work.
  • loading on the pane replaces the default skeleton with your own. loading on Pane.Body wins over it.
  • A wait outside Pane.Body still paints the column, with a skeleton header in place of yours.
  • loading.tsx is optional. Next partially prefetches a dynamic route only when it has one. Without it, a click waits for the server's first bytes before anything changes, and the frame's pending indicator covers that gap.
  • pending holds the pending indicator for a wait Roadie can't see, such as a client fetch without Suspense or a mutation.

Roadie marks a click on any Roadie link. For a navigation you start yourself, such as a router.push, report it with usePendingNavigation, documented on Linking alongside useRoadieLink. useNavigatorSecondary lives on Navigator.

Without a framework

Navigator never reads the URL, so any router works, including none. Every live example on this site is this shape.

  • Keep the path in state. Pass it as value and update it from onValueChange. A destination with an href also goes through your link, so both set the same path, which is harmless.
  • Give RoadieLinkProvider your own link, so list rows and Back update the same state.
  • Render the panes the path implies, in order.
const NavigateContext = createContext<(path: string) => void>(() => {})
const isPlainClick = (event: React.MouseEvent) =>
event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
function AppLink({ href, onClick, ...props }: RoadieLinkProps) {
const navigate = use(NavigateContext)
return (
<a
{...props}
href={href}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented || !isPlainClick(event)) return
event.preventDefault()
navigate(href)
}}
/>
)
}
function App() {
const [path, setPath] = useState('/account/tickets')
return (
<RoadieLinkProvider Link={AppLink}>
<NavigateContext value={setPath}>
<Navigator value={path} onValueChange={setPath}>
<Navigator.Primary aria-label='Account'>…</Navigator.Primary>
{panesFor(path)}
</Navigator>
</NavigateContext>
</RoadieLinkProvider>
)
}

An empty detail column

A route renders only the panes it has reached. Client state can mount one early, such as a detail column that asks you to pick an event. Pass reached={false} on it. It fills its column where two fit, but it never becomes the top pane, so a phone shows the list.

const events = [
  { id: 'muster-fest-glamping-4821', title: 'Muster Fest glamping', when: 'Mon 5 May · Rockhampton' },
  { id: 'midnight-frequency-1377', title: 'Midnight Frequency', when: 'Sat 12 Jul · The Longacre' }
]

function EmptyDetail() {
  const [selected, setSelected] = useState(null)
  const event = events.find(({ id }) => id === selected)
  return (
    <div className='h-[30rem] overflow-hidden rounded-2xl border border-subtle'>
      <Navigator className='h-full'>
        <Pane column='list'>
          <Pane.Header>
            <Pane.Title>Tickets</Pane.Title>
          </Pane.Header>
          <List>
            {events.map((e) => (
              <List.Item
                key={e.id}
                title={e.title}
                description={e.when}
                current={e.id === selected}
                onClick={() => setSelected(e.id)}
                chevron
              />
            ))}
          </List>
        </Pane>
        <Pane reached={event != null}>
          <Pane.Header onBack={() => setSelected(null)} backLabel='Tickets'>
            <Pane.Title>{event ? event.title : 'No event chosen'}</Pane.Title>
          </Pane.Header>
          <p className='pb-4 text-subtle'>
            {event ? event.when : 'Pick an event from the list.'}
          </p>
        </Pane>
      </Navigator>
    </div>
  )
}
render(<EmptyDetail />)

Why not intercepting routes

Parallel and intercepting routes can put a list and a detail under one URL too. Roadie recommends nested layouts instead.

  1. Under nested layouts the depth is in the URL, so there is one thing to reason about. Interception gives one URL two meanings.
  2. A reload and a click agree. Interception exists because they don’t. The deep URL resolves to a standalone twin, which renders one pane unless you rebuild the stack in it by hand.
  3. There are no duplicate route files, no default.tsx and no null catch-all. Interception needs all three.
  4. The reference app landed here after trying interception first.
  5. Next rejects intercepting routes under static export, so these docs could never demo them. Nested layouts demo fine.

The cost is the empty page.tsx at each level. If a real need for interception appears, it can come back.

Previous page← LinkingNext pagePerformance →