# Tab bar

A phone's bottom tab bar: up to five main places, each an icon above its word, the current one marked as the current page. Give it to AppLayout's `tabBar`, which draws it as the frame's last row below `tabBarAt` (640 px of frame by default) in place of the menu button; above that, the sidebar does the job and the bar isn't drawn.

Each destination is a link when it has an `href` (a plain click goes through `onNavigate` when you give one, for your router) and a button otherwise. Every target is at least 44 px tall, and the bar is padded for the phone's home indicator (`env(safe-area-inset-bottom)`). A badge is read in the destination's name ("Inbox, 3 new"), not colour alone. `more` adds a More button that opens AppLayout's sidebar sheet, for the places beyond the bar; it is drawn only inside an AppLayout. A sixth destination is a warning in development: put it behind More.

- Registry name: `tab-bar` (`@candor/tab-bar`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/tab-bar

## Install

```bash
npx candor-ui add tab-bar
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/tab-bar`.
It brings in: app-layout, locale, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { Bot, Inbox, MessagesSquare, Settings, Workflow } from "lucide-react"
import { AppLayout } from "@/components/candor/app-layout"
import { AppSidebar } from "@/components/candor/app-sidebar"
import { TabBar, type TabBarDestination } from "@/components/candor/tab-bar"

const destinations: TabBarDestination[] = [
  { id: "threads", label: "Threads", icon: MessagesSquare },
  // A badge only when the service gave a count; it's read in the name: "Inbox, 2 new".
  { id: "inbox", label: "Inbox", icon: Inbox, badge: 2 },
  { id: "agents", label: "Agents", icon: Bot },
]

export default function Example() {
  const [place, setPlace] = React.useState("threads")
  return (
    // A phone-sized frame: below tabBarAt (640 by default) the tab bar is the last row and More opens the sidebar.
    <div className="h-[480px] w-full max-w-[360px] overflow-hidden rounded-lg border">
      <AppLayout
        sidebar={
          <AppSidebar
            brand={<span className="px-1 text-sm font-semibold">Keel</span>}
            nav={[
              ...destinations.map(({ id, label, icon }) => ({ id, label, Icon: icon })),
              { id: "flows", label: "Flows", Icon: Workflow },
              { id: "settings", label: "Settings", Icon: Settings },
            ]}
            activeId={place}
            onNavigate={setPlace}
            user={{ name: "Sam", email: "sam@acme.example" }}
          />
        }
        tabBar={<TabBar destinations={destinations} activeId={place} onNavigate={setPlace} more />}
      >
        <header className="flex h-11 shrink-0 items-center border-b px-4">
          <h2 className="text-sm font-semibold capitalize">{place}</h2>
        </header>
        <div className="flex-1 overflow-y-auto p-4 text-sm text-muted-foreground">Flows and Settings are behind More, in the sidebar.</div>
      </AppLayout>
    </div>
  )
}
```

## <TabBar>

Props:

- `destinations: TabBarDestination[]`: The places, in order; up to five.
- `activeId?: string | undefined`: The id of the place the person is in, marked as the current page, when you control it.
- `defaultActiveId?: string | undefined`: The place to start in, when the bar keeps it itself: with this and no `activeId`, a destination chosen in this page (not opened in a new tab) becomes the current one, and `onNavigate` is still called. Without either, none is marked.
- `onNavigate?: ((id: string) => void) | undefined`: Called with a destination's id when it's chosen. For one with an `href`, a plain click calls this instead of following the link (for an app's own router); other clicks open the link as usual.
- `label?: string | undefined`: The bar's accessible name. Defaults to "Main".
- `more?: boolean | undefined` (default `false`): Adds a More button that opens AppLayout's sidebar sheet, for places beyond the bar. Defaults to false.
- `className?: string | undefined`: Classes for the bar, merged with Candor's.

## Strings (`tabBar`)

The fixed words, English by default. Override any of them under `tabBar` in `<LocaleProvider strings={{ tabBar: { … } }}>`; the table is exported as `tabBarStrings`.

- `main`: Main
- `more`: More
- `withBadge`: {label}, {count} new

## Types

### TabBarDestination

One place in the tab bar.

```ts
{
  /** Passed to `onNavigate`, and compared with `activeId`. */
  id: string;
  /** The words under the icon; always shown, never icon-only. */
  label: string;
  /** The icon above the label. */
  icon: LucideIcon;
  /** Where it goes. With an `href` the destination is a link (it opens in a new tab, its address can be copied); without one, a button. */
  href?: string | undefined;
  /** How many new things are there, when the service says; drawn as a count and read in the name ("Inbox, 3 new"). 0 or null draws none. */
  badge?: number | null | undefined;
  /**
   * The destination's name while it has a badge, in place of "{label}, {count} new", when "new" would say something
   * false ("Approvals, 3 waiting": NavRail's `navRailTabs` sets it). Start it with `label`, so the name starts with the
   * visible words. Without it, the name is 4.3's.
   */
  badgeLabel?: string | undefined;
}
```

## Motion

- **tint** (tint): A destination's word and icon darken when the pointer is over it.

Set with `--cd-motion-tab-bar-<moment>-*` in your brand file; the families are in the design reference.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
