Skip to content
Tier

Tab bar

Pro

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. The app's link render draws the link when one is set (LinkProvider, CandorProvider's link), else a plain <a>. 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.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0 and 4.2.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/tab-bar

Brings in: app-layout, link, locale, tokens, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"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>

PropTypeDefaultDescription
destinationsrequiredTabBarDestination[]—The places, in order; up to five.
activeIdstring | undefined—The id of the place the person is in, marked as the current page, when you control it.
defaultActiveIdstring | 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.
labelstring | undefined—The bar's accessible name. Defaults to "Main".
moreboolean | undefinedfalseAdds a More button that opens AppLayout's sidebar sheet, for places beyond the bar. Defaults to false.
classNamestring | undefined—Classes for the bar, merged with Candor's.

Strings

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

KeyEnglish
mainMain
moreMore
withBadge{label}, {count} new

Motion

MomentFamilyWhat it does
tinttintA 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.

Types

TabBarDestination
One place in the tab bar.
{ /** 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; }