> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cskn.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Stack navigation

> Native-feeling screen navigation: pushes, sheets, tabs, gestures and back handling for iOS, Android, macOS and Windows.

`StackNavigator` makes your app's mobile surface feel like a native app rather than a web page: a stack of screens that slide in, sheets that come up from the bottom, a tab bar with a stack per tab, large titles that fold into the bar, and the back gestures each platform expects. It draws the iOS idiom on iOS and the Material idiom on Android, and turns into a macOS or Windows window layout on desktop.

The navigator takes its own full-screen, fixed-position layer. Use it as the root of your app's mobile (or desktop) surface, not inside a page. Use one navigator per page.

```tsx theme={null}
import {
  useStackNav, StackNavigator, StackScreen, StackBackButton,
  StackTabBar, GroupedSection, GroupedRow,
} from '@cskn/ui';

type Route = { type: 'list'; id: string } | { type: 'new-list' };

export default function MobileApp() {
  const nav = useStackNav<Route, 'lists' | 'settings'>({
    tabs: ['lists', 'settings'],
    presentation: (r) => (r.type === 'new-list' ? 'modal' : 'push'),
    restore: 'my-app',
  });

  return (
    <StackNavigator
      nav={nav}
      root={(tab) =>
        tab === 'settings' ? <SettingsScreen /> : (
          <StackScreen title="Lists" largeTitle tabBar onRefresh={() => refetch()}>
            <GroupedSection>
              {lists.map((l) => (
                <GroupedRow key={l.id} title={l.name} chevron
                  onPress={() => nav.push({ type: 'list', id: l.id })} />
              ))}
            </GroupedSection>
          </StackScreen>
        )
      }
      render={(route) =>
        route.type === 'list' ? (
          <StackScreen title="List" tabBar left={<StackBackButton label="Lists" onPress={() => nav.back()} />}>
            <ListDetail id={route.id} />
          </StackScreen>
        ) : (
          <NewListForm />
        )
      }
      tabBar={
        <StackTabBar items={[
          { key: 'lists', label: 'Lists', icon: 'list' },
          { key: 'settings', label: 'Settings', icon: 'settings' },
        ]} />
      }
    />
  );
}
```

## The stack lives in history

Every push is a browser history entry. Android's back button and gesture, the iOS edge swipe, the browser's back button and your own Back button all arrive as the same history pop. You never write `pushState` or `popstate` handlers yourself, and back behaves correctly everywhere for free.

## useStackNav

```ts theme={null}
const nav = useStackNav<Route, Tab>(options);
```

`Route` is your own type describing a screen. Keep routes to plain data with ids rather than objects; they are stored for `restore` and must survive JSON.

**presentation** `(route) =\> 'push' | 'modal' | 'sheet' | 'fullScreen'`

How each route appears. Defaults to `push` for every route. See [Presentations](#presentations).

**tabs** `Tab[]`

Tabs, each with its own stack. The first is the start tab. See [Tabs](#tabs).

**detents** `(route) =\> ('medium' | 'large')[]`

The heights a `sheet` route rests at, the first being where it opens. Default `['medium', 'large']`.

**restore** `string`

Keep the stack in `sessionStorage` under this key, so a page reload rebuilds it without animation.

**split** `boolean | \{ minWidth?: number \}`

Two columns on wide screens. `true` means from 768 px wide. See [Two columns](#two-columns).

**onChange** `(\{ route, tab, depth \}) =\> void`

The screen on top changed (pushed, popped, tab switched), and once on mount. `route` is `null` at a root. Use it for screen-view analytics or the document title.

It returns a `nav` object:

| Member | Description |
| - | - |
| `stack` | The live entries of the current tab, bottom first. Empty at the root. |
| `push(route, { animated? })` | Push a screen. |
| `back({ animated? })` | Pop one screen. |
| `replace(route)` | Swap the top screen at the same depth, without animation or a new history entry. |
| `popToRoot(then?)` | Pop everything; `then` runs once history has settled. |
| `open(routes, { animated?, tab? })` | Build the stack to a destination, as if navigated: pop everything, then push each route. Back walks back through them. Use it for deep links and notifications. |
| `show(route)` | Pick a detail from the root list: replaces the right column in two-column mode, pushes otherwise. |
| `activeTab`, `selectTab(tab, then?)` | The tab showing, and switch tabs from code. |
| `hasModal` | Whether a `modal`, `sheet` or `fullScreen` is open. |
| `split` | Whether the navigator is currently in two columns. |

### Deep links

Combine `open` with the SDK's [deep link](https://docs.cskn.dev/sdk/native/deep-links.md) API:

```tsx theme={null}
import { onDeepLink } from '@cskn/sdk';

useEffect(() => onDeepLink(({ params }) => {
  if (params.list) nav.open([{ type: 'list', id: params.list }], { tab: 'lists' });
}), [nav]);
```

## Presentations

| Presentation | iOS | Android | Use for |
| - | - | - | - |
| `push` | Slides in from the right | Slides in from the right, with Material timing | Details |
| `modal` | Page sheet; the screen behind shrinks into a card | Full-screen dialog | Forms, composers |
| `sheet` | Resizable sheet resting at half (`medium`) and full (`large`) height | Same | Pickers, filters, short details |
| `fullScreen` | Covers everything; can't be dragged away | Full-screen dialog | Players, camera, a flow that must be finished |

A `sheet` is dragged by its grabber, its bar or content scrolled to the top: up to grow, down to half height, down from half to close. Tapping the dimmed area above closes it. It goes to full height when the keyboard opens. Inside a sheet, `useStackSheet()` returns `{ detent, detents, setDetent }`.

In two-column mode and on desktop, sheets and modals become centered cards.

## Tabs

With `tabs`, every tab keeps its own stack, as a native tab bar controller does. Switching tabs leaves the other tab's pushed screens, scroll positions and half-filled forms in place, hidden, and brings them back on return. Each tab's root mounts the first time it is shown and then stays mounted.

Draw the roots with `root={(tab) => …}` and put a [`StackTabBar`](https://docs.cskn.dev/ui/stack/screens#stacktabbar) in `tabBar`. Inside a navigator the tab bar binds itself: a tap switches tabs; a second tap on the current tab pops to its root, and at the root scrolls to the top.

Android's back walks the current tab's stack. At the root of a tab other than the first, it goes to the first tab, as Material apps do.

`hidesTabBar={(route) => …}` slides the tab bar away when a full-bleed screen (a reader, a player) is pushed, and back when it is popped. Don't pass `tabBar` to that route's `StackScreen`.

## Two columns

With `split`, wide screens (tablets, desktop windows) show the root on the left and the first pushed screen on the right. Select from the root list with `nav.show(route)`, which replaces the right column instead of stacking history, and mark the chosen row with `GroupedRow selected`. `placeholder` is what the right column shows before anything is picked, and `primaryWidth` (default 360) sets the left column's width.

On Android in two columns, the bottom tab bar becomes Material's navigation rail on the left edge.

## Guarding edited forms

`useStackGuard` asks before an edited screen is left, however it's being closed: a Cancel button, Android's back, or the iOS edge swipe. The stack puts the screen back and only lets the pop through on "discard".

```tsx theme={null}
import { useStackGuard } from '@cskn/ui';

const dirty = JSON.stringify(form) !== JSON.stringify(initial);
const release = useStackGuard(dirty, {
  title: 'Discard changes?',
  discardLabel: 'Discard',
  keepLabel: 'Keep editing',
});

async function save() {
  await api.put(`/apps/my-app/lists/${id}`, form);
  release();   // the changes are saved; don't ask
  nav.back();
}
```

Outside a `StackNavigator` it does nothing.

## Gestures and back

| Platform | Back |
| - | - |
| iOS | Swipe from the left edge on a pushed screen; the screen follows the finger. With `look="modern"` the swipe starts anywhere on the screen, except over horizontally scrolling content, swipe rows, fields, canvas, video and elements marked `data-stack-no-swipe`. Pull a sheet down to close it. |
| Android | The back button and gesture. On Android 14+ the predictive back gesture previews the screen behind as the finger moves. |
| Desktop | Esc closes the top sheet; ⌘\[ / Alt+←, the keyboard's Back key, the mouse back button and a two-finger swipe right on a trackpad go back one screen. |

The navigator turns off the shell's own WebView swipe while it runs, so the two don't fight. In shells that predate this, the WebView's gesture still works.

↑ and ↓ (and Home / End) move between rows on a screen, and Enter opens one. When a screen slides in, focus moves to its title so screen readers announce it; when you come back, focus returns to the row that opened it.

## Navigator props

**nav** `StackNav` *required*

The object from `useStackNav`.

**root** `ReactNode | (tab) =\> ReactNode` *required*

The root screen, or a function drawing each tab's root.

**render** `(route) =\> ReactNode` *required*

Draws a pushed route, usually as a `StackScreen`.

**tabBar** `ReactNode`

A `StackTabBar`. It stays put while screens push under it and is covered by sheets.

**hidesTabBar** `(route) =\> boolean`

Routes that slide the tab bar away.

**look** `'classic' | 'modern'`

`modern` is iOS 26 Liquid Glass and Material 3 Expressive. Defaults to `UIProvider`'s `look`.

**os** `'ios' | 'android' | 'macos' | 'windows'`

Force an idiom instead of detecting it.

**systemBars** `boolean` (default: true)

Run the shell's chrome to match: hide its header (every screen draws its own bar), color the status bar for the color scheme, and on Android tint the navigation bar like the tab bar. In the desktop shell the bar carries the window controls. Pass `false` to manage them yourself.

**primaryWidth** `number` (default: 360)

Left column width in two-column mode.

**placeholder** `ReactNode`

Right column content before anything is picked.

**labels** `\{ expandSheet?, collapseSheet? \}`

Screen-reader names of a sheet's grabber. English by default.

## Idioms and looks

`useStackOS()` returns the idiom in use: `'ios'`, `'android'`, `'macos'` or `'windows'`. `isPhoneOS(os)` tells the phone idioms apart. Use them where your own UI should differ, for example an "add" button in the bar on iOS and desktop but a `fab` on Android.

On desktop the navigator draws a macOS window (a unified toolbar, a translucent source-list sidebar in two columns, sheets that drop from the top of the window) or a Windows 11 one (a Fluent bar, a NavigationView sidebar, centered dialogs). Phone-only parts such as the FAB and edge swipes don't appear there.

`useStackLook()` returns `'classic'` or `'modern'`. `useStackTransitionEnd()` is `false` until the screen has finished sliding in; `<StackDeferred fallback={…}>` renders heavy content only after that, so the slide stays smooth. `useStackPaneRoot()` is `true` for the first screen of the right column in two-column mode, which has no Back button.

## Next

> [card] [Screens and bars](https://docs.cskn.dev/ui/stack/screens.md)
>
> StackScreen, bar buttons, search, tab bar

> [card] [Grouped forms](https://docs.cskn.dev/ui/stack/grouped-forms.md)
>
> Settings-style lists and form fields

> [card] [Lists and pages](https://docs.cskn.dev/ui/stack/lists.md)
>
> Swipe rows, long lists, paging

> [card] [Keyboard shortcuts](https://docs.cskn.dev/ui/stack/shortcuts.md)
>
> useShortcut and shortcut labels


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.