# Trail

A navigation trail that shows breadcrumbs on desktop and a back link with history on mobile.

Trail is an open-source pattern from suiss/ui for React. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/trail

**React**

**Example**

```tsx
import * as React from "react"
import { Trail, type TrailItem } from "@/components/patterns/trail"

const article: TrailItem[] = [
  { label: "Support", href: "#support" },
  { label: "Account", href: "#account" },
  { label: "Security", href: "#security" },
  { label: "Turn on two-factor authentication" },
]

type Page = { label: string; parent?: string; children: string[] }

const pages: Record<string, Page> = {
  support: { label: "Support", children: ["account", "billing"] },
  account: {
    label: "Account",
    parent: "support",
    children: ["security", "profile"],
  },
  billing: { label: "Billing", parent: "support", children: [] },
  security: {
    label: "Security",
    parent: "account",
    children: ["two-factor", "passwords"],
  },
  profile: { label: "Profile", parent: "account", children: [] },
  "two-factor": {
    label: "Turn on two-factor authentication",
    parent: "security",
    children: [],
  },
  passwords: {
    label: "Change your password",
    parent: "security",
    children: [],
  },
}

function pageOf(id: string) {
  const page = pages[id]
  if (!page) throw new Error(`Unknown page ${id}`)
  return page
}

function trailTo(id: string): TrailItem[] {
  const items: TrailItem[] = []
  for (let key: string | undefined = id; key; key = pageOf(key).parent) {
    items.unshift({ label: pageOf(key).label, href: `#${key}` })
  }
  return items.map((item, index) =>
    index === items.length - 1 ? { label: item.label } : item
  )
}

function TrailExample(props: Partial<React.ComponentProps<typeof Trail>>) {
  const [page, setPage] = React.useState("two-factor")
  const current = pageOf(page)

  const navigation = {
    onClick: (event: React.MouseEvent<HTMLAnchorElement>) => {
      const id = event.currentTarget.getAttribute("href")?.slice(1)
      if (!(id && pages[id])) return
      event.preventDefault()
      setPage(id)
    },
  }

  return (
    <div className="flex flex-col gap-6">
      <Trail {...props} items={trailTo(page)} render={<a {...navigation} />} />
      <section className="flex flex-col gap-3">
        <h2 className="text-2xl font-semibold tracking-tight">
          {current.label}
        </h2>
        {current.children.length > 0 ? (
          <ul className="flex flex-col gap-1">
            {current.children.map((child) => (
              <li key={child}>
                <a
                  href={`#${child}`}
                  {...navigation}
                  className="text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
                >
                  {pageOf(child).label}
                </a>
              </li>
            ))}
          </ul>
        ) : (
          <p className="text-muted-foreground">
            Pick an earlier page in the trail to go back up.
          </p>
        )}
      </section>
    </div>
  )
}

export default function TrailDemo() {
  return <TrailExample items={article} />
}
```

`Trail` shows where the current page sits in a hierarchy. On desktop it renders a suiss/ui `Breadcrumb` that links every ancestor and marks the last item as the current page; with `maxItems`, it keeps the root and the nearest ancestors and folds the rest into an ellipsis.

On mobile (below `48rem`, via `useIsMobile`) it renders a single back link to the parent page, like an iOS navigation bar. When there is more than one ancestor, a long press, right-click, the Context Menu key or Shift+F10 opens a menu of all ancestors, nearest first. Use it on documentation, support and settings pages with nested structure.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/trail
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns trail
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react @phosphor-icons/react
```

2. Add the pieces it builds on: [Breadcrumb](https://ui.suiss.app/docs/components/breadcrumb), `use-mobile`.

3. Copy the source into `components/patterns/trail.tsx`:

```tsx title="components/patterns/trail.tsx"
"use client"

import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { useRender } from "@base-ui/react/use-render"
import { ArrowBendUpLeftIcon, CaretLeftIcon } from "@phosphor-icons/react"
import * as React from "react"
import {
  Breadcrumb,
  BreadcrumbEllipsis,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type TrailItem = {
  label: string
  href?: string
}

const LONG_PRESS_DELAY = 500
const LONG_PRESS_TOLERANCE = 10

function TrailAnchor(props: React.ComponentProps<"a">) {
  return <a {...props} />
}

function collapse(items: TrailItem[], maxItems: number | undefined) {
  const [first] = items
  if (!first || !maxItems || items.length <= maxItems) {
    return { items, hidden: 0, ellipsisAt: -1 }
  }
  if (maxItems === 1) {
    return { items: items.slice(-1), hidden: items.length - 1, ellipsisAt: 0 }
  }
  const tail = maxItems - 1
  return {
    items: [first, ...items.slice(items.length - tail)],
    hidden: items.length - tail - 1,
    ellipsisAt: 1,
  }
}

function Trail({
  items,
  maxItems,
  backLabel,
  ellipsisLabel = "More",
  render = <TrailAnchor />,
  className,
  ...props
}: React.ComponentProps<"nav"> & {
  items: TrailItem[]
  maxItems?: number
  backLabel?: string
  ellipsisLabel?: string
  render?: React.ReactElement
}) {
  const isMobile = useIsMobile()
  const parent = items.at(-2)
  const ancestors = items.slice(0, -1).reverse()
  const hasHistory = ancestors.length > 1
  const [historyOpen, setHistoryOpen] = React.useState(false)
  const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const startRef = React.useRef<{ x: number; y: number } | null>(null)
  const pressedRef = React.useRef(false)
  const touchingRef = React.useRef(false)
  const backRef = React.useRef<HTMLAnchorElement>(null)

  React.useEffect(() => () => clearTimeout(timerRef.current), [])

  const cancelPress = () => {
    clearTimeout(timerRef.current)
    startRef.current = null
  }

  const openHistory = () => {
    pressedRef.current = touchingRef.current
    setHistoryOpen(true)
  }

  const release = () => {
    cancelPress()
    touchingRef.current = false
    if (!pressedRef.current) return
    setTimeout(() => {
      pressedRef.current = false
    }, 300)
  }

  const back = useRender({
    defaultTagName: "a",
    render,
    props: {
      ref: backRef,
      href: parent?.href,
      "data-slot": "trail-back",
      ...(hasHistory && {
        "aria-haspopup": "menu" as const,
        "aria-expanded": historyOpen,
        onClick: (event: React.MouseEvent) => {
          if (pressedRef.current) event.preventDefault()
        },
        onContextMenu: (event: React.MouseEvent) => {
          event.preventDefault()
          cancelPress()
          openHistory()
        },
        onKeyDown: (event: React.KeyboardEvent) => {
          if (
            event.key === "ContextMenu" ||
            (event.shiftKey && event.key === "F10")
          ) {
            event.preventDefault()
            setHistoryOpen(true)
          }
        },
        onPointerDown: (event: React.PointerEvent) => {
          pressedRef.current = false
          if (event.pointerType === "mouse") return
          touchingRef.current = true
          startRef.current = { x: event.clientX, y: event.clientY }
          clearTimeout(timerRef.current)
          timerRef.current = setTimeout(() => {
            startRef.current = null
            openHistory()
          }, LONG_PRESS_DELAY)
        },
        onPointerMove: (event: React.PointerEvent) => {
          const start = startRef.current
          if (!start) return
          const moved = Math.hypot(
            event.clientX - start.x,
            event.clientY - start.y
          )
          if (moved > LONG_PRESS_TOLERANCE) cancelPress()
        },
        onPointerUp: release,
        onPointerCancel: release,
        onPointerLeave: cancelPress,
      }),
      className:
        "-ms-1.5 inline-flex min-h-11 touch-manipulation items-center gap-0.5 rounded-md pe-1.5 text-base text-primary outline-none select-none [-webkit-touch-callout:none] focus-visible:ring-3 focus-visible:ring-ring/50",
      children: (
        <>
          <CaretLeftIcon
            className="size-4.5 shrink-0 rtl:rotate-180"
            aria-hidden
          />
          <span className="truncate">{backLabel ?? parent?.label}</span>
        </>
      ),
    },
  })

  if (isMobile) {
    if (!parent) return null
    return (
      <nav
        data-slot="trail"
        aria-label="Back"
        className={cn("flex", className)}
        {...props}
      >
        {back}
        {hasHistory && (
          <MenuPrimitive.Root
            open={historyOpen}
            onOpenChange={(next) => {
              if (!next && pressedRef.current) return
              setHistoryOpen(next)
            }}
          >
            <MenuPrimitive.Portal>
              <MenuPrimitive.Positioner
                anchor={backRef}
                side="bottom"
                align="start"
                sideOffset={6}
                className="isolate z-50 outline-none"
              >
                <MenuPrimitive.Popup
                  data-slot="trail-history"
                  className="min-w-60 origin-(--transform-origin) overflow-hidden rounded-2xl bg-popover text-popover-foreground shadow-xl ring-1 ring-foreground/5 transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-starting-style:scale-90 data-starting-style:opacity-0 motion-reduce:transition-none dark:ring-foreground/10"
                >
                  {ancestors.map((item) => (
                    <MenuPrimitive.LinkItem
                      key={item.href ?? item.label}
                      data-slot="trail-history-item"
                      closeOnClick
                      href={item.href}
                      render={render}
                      className="flex min-h-11 items-center justify-between gap-6 px-4 py-2.75 text-base outline-none select-none not-first:border-t not-first:border-border data-highlighted:bg-accent [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5 data-highlighted:text-accent-foreground"
                    >
                      <span className="truncate">{item.label}</span>
                      <ArrowBendUpLeftIcon className="rtl:-scale-x-100" />
                    </MenuPrimitive.LinkItem>
                  ))}
                </MenuPrimitive.Popup>
              </MenuPrimitive.Positioner>
            </MenuPrimitive.Portal>
          </MenuPrimitive.Root>
        )}
      </nav>
    )
  }

  const { items: shown, hidden, ellipsisAt } = collapse(items, maxItems)

  return (
    <Breadcrumb data-slot="trail" className={className} {...props}>
      <BreadcrumbList>
        {shown.map((item, index) => {
          const last = index === shown.length - 1
          return (
            <React.Fragment key={item.href ?? item.label}>
              {index > 0 && <BreadcrumbSeparator />}
              {index === ellipsisAt && hidden > 0 && (
                <>
                  <BreadcrumbItem>
                    <BreadcrumbEllipsis>{ellipsisLabel}</BreadcrumbEllipsis>
                  </BreadcrumbItem>
                  <BreadcrumbSeparator />
                </>
              )}
              <BreadcrumbItem>
                {last ? (
                  <BreadcrumbPage>{item.label}</BreadcrumbPage>
                ) : (
                  <BreadcrumbLink render={render} href={item.href}>
                    {item.label}
                  </BreadcrumbLink>
                )}
              </BreadcrumbItem>
            </React.Fragment>
          )
        })}
      </BreadcrumbList>
    </Breadcrumb>
  )
}

export { Trail, type TrailItem }
```

4. Update the import paths to match your project.

## Usage

```tsx
import { Trail } from "@/components/patterns/trail"
```

```tsx
<TrailExample items={article} />
```

- Pass `items` in order from the root to the current page. Each `TrailItem` has a `label` and an optional `href`; the last item is the current page.
- On mobile, nothing renders when there is only one item.
- Use `render` to output links through your router, for example a framework `Link` element. It applies to breadcrumb links, the mobile back link and the history menu items.

## Examples

### Collapsed

Folds the middle of a deep trail into an ellipsis with `maxItems`.

**Example**

```tsx
import { Trail, type TrailItem } from "@/components/patterns/trail"

const deep: TrailItem[] = [
  { label: "Support", href: "#support" },
  { label: "Products", href: "#products" },
  { label: "Workspace", href: "#workspace" },
  { label: "Billing", href: "#billing" },
  { label: "Invoices", href: "#invoices" },
  { label: "Download an invoice as PDF" },
]

export default function TrailCollapsed() {
  return <Trail items={deep} maxItems={4} />
}
```

### Back label

Labels the mobile back link Back instead of the parent's name.

**Example**

```tsx
import { Trail, type TrailItem } from "@/components/patterns/trail"

const article: TrailItem[] = [
  { label: "Support", href: "#support" },
  { label: "Account", href: "#account" },
  { label: "Security", href: "#security" },
  { label: "Turn on two-factor authentication" },
]

export default function TrailBackLabel() {
  return <Trail items={article} backLabel="Back" />
}
```

### Two levels

Shows a trail with one parent and the current page.

**Example**

```tsx
import { Trail, type TrailItem } from "@/components/patterns/trail"

const article: TrailItem[] = [
  { label: "Support", href: "#support" },
  { label: "Account", href: "#account" },
  { label: "Security", href: "#security" },
  { label: "Turn on two-factor authentication" },
]

export default function TrailTwoLevels() {
  return <Trail items={article.slice(2)} />
}
```

## API Reference

### Trail

The navigation element that renders the breadcrumbs or the mobile back link.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `TrailItem[]` | Required | The pages from the root to the current page. |
| `maxItems` | `number` | - | The maximum number of items shown on desktop before the middle collapses into an ellipsis. |
| `backLabel` | `string` | - | Text for the mobile back link; defaults to the parent's label. |
| `ellipsisLabel` | `string` | `"More"` | The accessible label of the collapsed ellipsis. |
| `render` | `React.ReactElement` | `<TrailAnchor />` | The element used to render links. |

Also accepts the props of `React.ComponentProps<"nav">`.

## Accessibility

- On desktop the trail is a navigation landmark named breadcrumb, and the current page has `aria-current="page"`.
- On mobile it is a navigation landmark named Back. When a history menu is available, the back link has `aria-haspopup="menu"` and `aria-expanded`.
- The history menu opens from the keyboard with the Context Menu key or Shift+F10.

## Notes

Styling hooks: `data-slot="trail"`, `trail-back`, `trail-history` and `trail-history-item`. A long press opens the history only for touch and pen input, and cancels if the pointer moves more than 10 pixels; the press does not also follow the back link.

