# Example: Scroll follows focus

140 trades exceed the viewport, so jump keys reveal focus with minimal scroll instead of centering it.

Source: https://pretable.ai/examples/keyboard-navigation.md

```tsx KeyboardNavGrid.tsx
"use client";

import { useState } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { trades, type Trade } from "./data";

const VIEWPORT_HEIGHT = 320;
const IDLE_FOCUS =
  "No cell focused yet — click a cell, then try the keys below.";

export function KeyboardNavGrid() {
  // The reveal math this demo illustrates lives inside <PretableSurface> and
  // is not something the demo drives — focus is left uncontrolled here.
  // onFocusChange only reads the address back out so keystrokes can be
  // correlated with what moved, the same "echo the state a gesture changed"
  // pattern as column-layout and async-cell-editing.
  const [focusAddress, setFocusAddress] = useState(IDLE_FOCUS);

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Click a cell, then press <kbd>Cmd/Ctrl</kbd>+<kbd>End</kbd> to jump to
        the last cell in the grid — 140 trades, well past the fold. Watch how
        little the viewport moves: the revealed row lands at the bottom edge,
        not centered, and clears both the sticky header and the right-pinned{" "}
        <strong>Status</strong> column. <strong>ID</strong> is pinned left and{" "}
        <strong>Status</strong> is pinned right, so <kbd>Home</kbd> /{" "}
        <kbd>End</kbd> inside a row never scrolls a pinned cell out of view.
        From the first row, <kbd>↑</kbd> moves onto that column&rsquo;s header —
        the whole grid is one <kbd>Tab</kbd> stop, so the header is reached with
        the arrows rather than with <kbd>Tab</kbd>.
      </p>
      <PretableSurface<Trade>
        ariaLabel="Trade blotter"
        columns={columns}
        getRowId={(row) => row.id}
        rows={trades}
        viewportHeight={VIEWPORT_HEIGHT}
        onFocusChange={({ ref, columnId }) => {
          if (ref === null || columnId === null) {
            setFocusAddress(IDLE_FOCUS);
            return;
          }
          // Three kinds, not two. `{kind: "header"}` is where the cursor sits
          // after ArrowUp off the first row — the header joined the grid's
          // roving-tabindex model, so it is an address like any other and has
          // no row id to print.
          if (ref.kind === "header") {
            setFocusAddress(`header, column ${columnId}`);
            return;
          }
          const rowId = ref.kind === "data" ? ref.rowId : ref.groupId;
          setFocusAddress(`row ${rowId}, column ${columnId}`);
        }}
      />
      <p style={{ margin: "8px 0 0", fontSize: 13 }}>
        Focus: <code>{focusAddress}</code>
      </p>
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Trade } from "./data";

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

// ID pinned left, Status pinned right — the two sticky column groups the
// "clear of pinned chrome" rule has to reveal focus past. The middle columns
// are wide enough that the grid needs to scroll horizontally too, not just
// vertically, so Home/End inside a row exercise the same reveal math.
export const columns: PretableColumn<Trade>[] = [
  { id: "id", header: "ID", pinned: "left", widthPx: 80 },
  { id: "time", header: "Time", widthPx: 110 },
  { id: "account", header: "Account", widthPx: 120 },
  { id: "symbol", header: "Symbol", widthPx: 90 },
  { id: "side", header: "Side", widthPx: 80 },
  { id: "quantity", header: "Qty", type: "number", widthPx: 90 },
  {
    id: "price",
    header: "Price",
    type: "number",
    widthPx: 100,
    format: ({ value }) => usd.format(value as number),
  },
  { id: "status", header: "Status", pinned: "right", widthPx: 110 },
];
```

```ts data.ts
export interface Trade {
  id: string;
  time: string;
  account: string;
  symbol: string;
  side: "Buy" | "Sell";
  quantity: number;
  price: number;
  status: "Filled" | "Partial" | "Working" | "Cancelled";
}

const SYMBOLS = [
  "NVDA",
  "MSFT",
  "AAPL",
  "AMZN",
  "GOOGL",
  "META",
  "TSLA",
  "JPM",
  "XOM",
  "UNH",
];
const ACCOUNTS = ["Acct-104", "Acct-118", "Acct-142", "Acct-207"];
const STATUSES: Trade["status"][] = [
  "Filled",
  "Partial",
  "Working",
  "Cancelled",
];

const ROW_COUNT = 140;
const START_SECONDS = 9 * 3600 + 30 * 60; // market open, 09:30:00
const SECONDS_PER_TRADE = 11;

function pad(value: number): string {
  return String(value).padStart(2, "0");
}

function timeAt(index: number): string {
  const totalSeconds = START_SECONDS + index * SECONDS_PER_TRADE;
  const hours = Math.floor(totalSeconds / 3600) % 24;
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;
  return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}

// Generated rather than hand-written: this table exists to exercise keyboard
// scrolling past the fold, and 140 hand-typed rows would bury that behind
// noise. Time is monotonic, so jumping to the last row visibly reads as "end
// of the trading day" rather than an arbitrary cutoff.
export const trades: Trade[] = Array.from({ length: ROW_COUNT }, (_, i) => ({
  id: `T-${1000 + i}`,
  time: timeAt(i),
  account: ACCOUNTS[i % ACCOUNTS.length],
  symbol: SYMBOLS[i % SYMBOLS.length],
  side: i % 2 === 0 ? "Buy" : "Sell",
  quantity: 100 + (i % 12) * 25,
  price: 50 + ((i * 13) % 400) + (i % 4) * 0.25,
  status: STATUSES[i % STATUSES.length],
}));
```
