# Tab Bar

A floating bottom navigation bar whose overflow and extra sections expand from a More button.

Tab Bar is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/tab-bar

**React**

**Example**

```tsx
import * as React from "react"
import {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarLink,
  TabBarList,
  TabBarSection,
  TabBarSectionTitle,
  TabBarTrigger,
} from "@/components/ui/tab-bar"

const defaultItems = ["Home", "Nominees", "Directory", "Collections"]

const sections = [
  { title: "Awards", links: ["Winners", "Site of the Day", "Nominees"] },
  { title: "Inspiration", links: ["Collections", "Elements", "Resources"] },
  { title: "Directory", links: ["Professionals", "Agencies", "Freelancers"] },
  { title: "Market", links: ["Jobs", "New Events", "Products"] },
]

function TabBarExample({
  defaultOpen,
  items = defaultItems,
  withSections = true,
}: {
  defaultOpen?: boolean
  items?: string[]
  withSections?: boolean
}) {
  const [value, setValue] = React.useState("Home")

  return (
    <div className="h-full min-h-svh bg-background">
      <Backdrop />
      <TabBar aria-label="Main" defaultOpen={defaultOpen}>
        <TabBarList>
          {items.map((item) => (
            <TabBarItem
              key={item}
              isActive={value === item}
              onClick={() => setValue(item)}
            >
              {item}
            </TabBarItem>
          ))}
        </TabBarList>
        <TabBarContent>
          {withSections &&
            sections.map((section) => (
              <TabBarSection key={section.title}>
                <TabBarSectionTitle>{section.title}</TabBarSectionTitle>
                {section.links.map((link) => (
                  <TabBarLink
                    key={link}
                    href={`#${link.toLowerCase().replaceAll(" ", "-")}`}
                    isActive={value === link}
                    onClick={() => setValue(link)}
                  >
                    {link}
                  </TabBarLink>
                ))}
              </TabBarSection>
            ))}
        </TabBarContent>
        <TabBarTrigger>More</TabBarTrigger>
      </TabBar>
    </div>
  )
}

function Backdrop() {
  return (
    <div className="grid grid-cols-2 gap-3 p-4 md:grid-cols-4">
      {Array.from({ length: 16 }, (_, index) => `Card ${index + 1}`).map(
        (card) => (
          <div
            key={card}
            className="flex aspect-[4/3] items-end rounded-2xl bg-primary/15 p-4 text-sm font-semibold text-primary odd:bg-secondary odd:text-foreground"
          >
            {card}
          </div>
        )
      )}
    </div>
  )
}

export default function TabBarDemo() {
  return (
    <div className="h-[32rem] transform-gpu overflow-hidden">
      <TabBarExample />
    </div>
  )
}
```

`TabBar` renders a fixed `nav` centered at the bottom of the screen. `TabBarList` holds the primary `TabBarItem` buttons. `TabBarTrigger` is the More button: pressing it expands `TabBarContent` above the list, with a spring animation, to show `TabBarSection` columns. Each section has a `TabBarSectionTitle` and a set of `TabBarLink` entries.

The list measures itself and moves items that don't fit into the expanded content, where they appear as links. When every item fits and there are no sections, the More button is hidden. Use `TabBar` for top-level navigation on any screen size; for in-page panels, use `Tabs`, and for an app's side navigation, use `Sidebar`.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/tab-bar
```

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 tab-bar
```

### 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: [Button](https://ui.suiss.app/docs/components/button).

3. Copy the source into `components/ui/tab-bar.tsx`:

```tsx title="components/ui/tab-bar.tsx"
"use client"

import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { ListIcon, XIcon } from "@phosphor-icons/react"
import * as React from "react"
import { flushSync } from "react-dom"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"

type TabBarProps = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

type TabBarContextProps = {
  open: boolean
  present: boolean
  setOpen: (open: boolean) => void
  contentId: string
  triggerRef: React.RefObject<HTMLButtonElement | null>
  overflow: React.ReactElement[]
  setOverflow: (overflow: React.ReactElement[]) => void
  hasSections: boolean
  setHasSections: (hasSections: boolean) => void
}

const TabBarContext = React.createContext<TabBarContextProps | null>(null)

const TabBarOverflowContext = React.createContext(false)

const tabBarLinkClassName =
  "relative ms-0.5 flex min-h-9 items-center border-s border-dotted border-input ps-3.25 text-sm text-foreground md:text-base outline-none transition-colors select-none hover:text-muted-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-active:text-primary"

function useTabBar() {
  const context = React.useContext(TabBarContext)

  if (!context) {
    throw new Error("useTabBar must be used within a <TabBar />")
  }

  return context
}

function TabBar({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  className,
  style,
  ref: forwardedRef,
  ...props
}: React.ComponentProps<"nav"> & TabBarProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const open = openProp ?? uncontrolledOpen
  const ref = React.useRef<HTMLElement>(null)
  const triggerRef = React.useRef<HTMLButtonElement>(null)
  const contentId = React.useId()
  const [closedWidth, setClosedWidth] = React.useState<number>()
  const [innerWidth, setInnerWidth] = React.useState<number>()
  const [overflow, setOverflow] = React.useState<React.ReactElement[]>([])
  const [hasSections, setHasSections] = React.useState(false)
  const [present, setPresent] = React.useState(open)

  React.useEffect(() => {
    if (open) {
      setPresent(true)
      return
    }
    const reducedMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches
    if (reducedMotion) {
      setPresent(false)
      return
    }
    const timeout = setTimeout(() => setPresent(false), 400)
    return () => clearTimeout(timeout)
  }, [open])

  React.useLayoutEffect(() => {
    const nav = ref.current
    if (!nav || ((open || present) && closedWidth !== undefined)) return
    const measure = () => {
      setClosedWidth(nav.offsetWidth)
      setInnerWidth(nav.clientWidth)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(nav)
    return () => observer.disconnect()
  }, [open, present, closedWidth])

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  React.useEffect(() => {
    if (!open) return
    const handlePointerDown = (event: PointerEvent) => {
      if (!ref.current?.contains(event.target as Node)) setOpen(false)
    }
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return
      setOpen(false)
      triggerRef.current?.focus()
    }
    document.addEventListener("pointerdown", handlePointerDown)
    document.addEventListener("keydown", handleKeyDown)
    return () => {
      document.removeEventListener("pointerdown", handlePointerDown)
      document.removeEventListener("keydown", handleKeyDown)
    }
  }, [open, setOpen])

  const contextValue = React.useMemo<TabBarContextProps>(
    () => ({
      open,
      present,
      setOpen,
      contentId,
      triggerRef,
      overflow,
      setOverflow,
      hasSections,
      setHasSections,
    }),
    [open, present, setOpen, contentId, overflow, hasSections]
  )

  return (
    <TabBarContext.Provider value={contextValue}>
      <nav
        ref={(node) => {
          ref.current = node
          if (typeof forwardedRef === "function") return forwardedRef(node)
          if (forwardedRef) forwardedRef.current = node
        }}
        data-slot="tab-bar"
        data-open={open ? "" : undefined}
        data-overflowing={overflow.length > 0 ? "" : undefined}
        data-sections={hasSections ? "" : undefined}
        className={cn(
          "group/tab-bar fixed inset-x-0 [--tab-bar-spring:linear(0,0.032,0.112,0.219,0.338,0.459,0.573,0.676,0.766,0.84,0.9,0.947,0.982,1.007,1.023,1.033,1.037,1.038,1.037,1.033,1.029,1.024,1.02,1.015,1.012,1.008,1.006,1.003,1.002,1,1,0.999,1)] bottom-[max(--spacing(4),env(safe-area-inset-bottom))] z-20 mx-auto grid max-h-[calc(100%-(--spacing(8)))] w-fit max-w-[calc(100%-(--spacing(8)))] grid-cols-[minmax(0,1fr)_auto] grid-rows-[minmax(0,1fr)_auto] rounded-3xl border border-foreground/5 bg-card p-1 shadow-lg data-overflowing:w-full dark:border-foreground/10",
          className
        )}
        style={
          {
            width: open || present ? closedWidth : undefined,
            "--tab-bar-width": innerWidth ? `${innerWidth}px` : undefined,
            ...style,
          } as React.CSSProperties
        }
        {...props}
      />
    </TabBarContext.Provider>
  )
}

function TabBarList({
  className,
  children,
  ref: forwardedRef,
  ...props
}: React.ComponentProps<"div">) {
  const { open, present, setOverflow } = useTabBar()
  const ref = React.useRef<HTMLDivElement>(null)
  const widthsRef = React.useRef<number[]>([])
  const items = React.Children.toArray(children).filter(React.isValidElement)
  const itemCount = items.length
  const [visibleCount, setVisibleCount] = React.useState(itemCount)

  React.useLayoutEffect(() => {
    const list = ref.current
    if (!list || open || present || itemCount === 0) return
    widthsRef.current = widthsRef.current.slice(0, itemCount)
    const measure = () => {
      const nav = list.closest<HTMLElement>("[data-slot=tab-bar]")
      const trigger = nav?.querySelector<HTMLElement>(
        "[data-slot=tab-bar-trigger]"
      )
      const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0
      const stretched = nav?.hasAttribute("data-overflowing") ?? false
      const widths = [...list.children].map((child, index) => {
        const item = child as HTMLElement
        if (
          !stretched ||
          item.hasAttribute("data-overflow") ||
          widthsRef.current[index] === undefined
        ) {
          widthsRef.current[index] = item.getBoundingClientRect().width
        }
        return widthsRef.current[index]
      })
      const triggerSpace = trigger
        ? trigger.getBoundingClientRect().width + gap
        : 0
      const space =
        list.getBoundingClientRect().width +
        (trigger?.hasAttribute("data-idle") ? 0 : triggerSpace)
      const fits = (available: number) => {
        let used = 0
        let count = 0
        for (const width of widths) {
          used += width + (count > 0 ? gap : 0)
          if (used > available + 1) break
          count++
        }
        return count
      }
      const needsTrigger =
        nav?.hasAttribute("data-sections") || fits(space) < widths.length
      return needsTrigger ? fits(space - triggerSpace) : widths.length
    }
    setVisibleCount(measure())
    const observer = new ResizeObserver(() =>
      flushSync(() => setVisibleCount(measure()))
    )
    observer.observe(list)
    return () => observer.disconnect()
  }, [itemCount, open, present])

  React.useLayoutEffect(() => {
    setOverflow(
      React.Children.toArray(children)
        .filter(React.isValidElement)
        .slice(visibleCount)
    )
  }, [children, visibleCount, setOverflow])

  return (
    <div
      ref={(node) => {
        ref.current = node
        if (typeof forwardedRef === "function") return forwardedRef(node)
        if (forwardedRef) forwardedRef.current = node
      }}
      data-slot="tab-bar-list"
      inert={open || undefined}
      className={cn(
        "relative col-start-1 row-start-2 flex min-w-0 gap-1 overflow-hidden rounded-full transition-[opacity,visibility] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/tab-bar:duration-100 group-data-open/tab-bar:pointer-events-none group-data-open/tab-bar:invisible group-data-open/tab-bar:opacity-0 group-has-[[data-slot=tab-bar-trigger]:not([data-idle])]/tab-bar:me-1 motion-reduce:transition-none",
        className
      )}
      {...props}
    >
      {items.map((item, index) =>
        index < visibleCount
          ? item
          : React.cloneElement(
              item as React.ReactElement<object>,
              {
                "data-overflow": "",
                "aria-hidden": true,
                tabIndex: -1,
              } as object
            )
      )}
    </div>
  )
}

function TabBarItem({
  render,
  isActive = false,
  className,
  ...props
}: useRender.ComponentProps<"button"> &
  React.ComponentProps<"button"> & {
    isActive?: boolean
  }) {
  const inOverflow = React.useContext(TabBarOverflowContext)
  const { setOpen } = useTabBar()

  return useRender({
    defaultTagName: "button",
    props: mergeProps<"button">(
      {
        type: render ? undefined : "button",
        "aria-current": isActive ? "page" : undefined,
        onClick: inOverflow ? () => setOpen(false) : undefined,
        className: cn(
          inOverflow
            ? cn(tabBarLinkClassName, "text-start")
            : cn(
                buttonVariants({ variant: "ghost" }),
                "shrink-0 font-medium text-muted-foreground transition-[color,background-color] duration-200 data-active:bg-secondary data-active:text-primary data-overflow:invisible data-overflow:absolute group-data-overflowing/tab-bar:flex-1 disabled:opacity-50"
              ),
          className
        ),
      },
      props
    ),
    render,
    state: {
      slot: inOverflow ? "tab-bar-link" : "tab-bar-item",
      active: isActive,
    },
  })
}

function TabBarTrigger({
  className,
  children,
  onClick,
  ref,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { open, setOpen, contentId, triggerRef, overflow, hasSections } =
    useTabBar()

  const idle = !open && overflow.length === 0 && !hasSections

  return (
    <Button
      ref={(node) => {
        triggerRef.current = node
        if (typeof ref === "function") return ref(node)
        if (ref) ref.current = node
      }}
      data-slot="tab-bar-trigger"
      data-idle={idle ? "" : undefined}
      aria-hidden={idle || undefined}
      tabIndex={idle ? -1 : undefined}
      aria-expanded={open}
      aria-controls={contentId}
      variant="secondary"
      className={cn(
        "col-start-2 row-start-2 w-auto justify-self-end gap-2 data-idle:pointer-events-none data-idle:invisible data-idle:absolute transition-[width,color] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/tab-bar:duration-650 group-data-open/tab-bar:ease-(--tab-bar-spring) [interpolate-size:allow-keywords] group-data-open/tab-bar:w-[calc(var(--tab-bar-width,auto)-(--spacing(2)))] max-w-[calc(var(--tab-bar-width,100vw)-(--spacing(2)))] motion-reduce:transition-none text-muted-foreground hover:text-foreground",
        className
      )}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) setOpen(!open)
      }}
      {...props}
    >
      <span
        aria-hidden
        className="relative size-4 *:absolute *:inset-0 *:size-4 *:transition-[opacity,rotate,scale] *:duration-400 *:ease-[cubic-bezier(0.32,0.72,0,1)] group-data-open/tab-bar:*:duration-650 group-data-open/tab-bar:*:ease-(--tab-bar-spring) motion-reduce:*:transition-none"
      >
        <ListIcon
          className="group-data-open/tab-bar:scale-50 group-data-open/tab-bar:rotate-90 group-data-open/tab-bar:opacity-0"
        />
        <XIcon
          className="scale-50 -rotate-90 opacity-0 group-data-open/tab-bar:scale-100 group-data-open/tab-bar:rotate-0 group-data-open/tab-bar:opacity-100"
        />
      </span>
      {children}
    </Button>
  )
}

function TabBarContent({
  className,
  children,
  ...props
}: React.ComponentProps<"div">) {
  const { open, present, contentId, overflow, setHasSections } = useTabBar()
  const hasSections = React.Children.toArray(children).length > 0
  const [settled, setSettled] = React.useState(false)

  React.useEffect(() => {
    if (!open) {
      setSettled(false)
      return
    }
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setSettled(true)
    }
  }, [open])

  React.useEffect(() => {
    setHasSections(hasSections)
    return () => setHasSections(false)
  }, [hasSections, setHasSections])

  if (!present) return null

  return (
    <div
      id={contentId}
      data-slot="tab-bar-content"
      data-closing={open ? undefined : ""}
      data-settled={settled ? "" : undefined}
      onTransitionEnd={(event) => {
        if (event.target === event.currentTarget && open) setSettled(true)
      }}
      className={cn(
        "col-span-2 row-start-1 mb-1 grid h-auto max-h-full self-end [interpolate-size:allow-keywords] min-h-0 w-full overflow-hidden overscroll-contain data-settled:overflow-y-auto grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-x-4 gap-y-5 px-3 py-3 transition-[height,opacity,margin,padding] duration-650 ease-(--tab-bar-spring) data-closing:duration-400 data-closing:ease-[cubic-bezier(0.32,0.72,0,1)] starting:mb-0 starting:h-0 starting:py-0 starting:opacity-0 data-closing:mb-0 data-closing:h-0 data-closing:py-0 data-closing:opacity-0 *:transition-[opacity,filter,scale] *:delay-100 *:duration-450 *:ease-[cubic-bezier(0.32,0.72,0,1)] *:starting:scale-95 *:starting:opacity-0 *:starting:blur-sm data-closing:*:scale-95 data-closing:*:opacity-0 data-closing:*:blur-sm data-closing:*:delay-0 data-closing:*:duration-150 motion-reduce:transition-none motion-reduce:*:transition-none md:px-4",
        className
      )}
      {...props}
    >
      {overflow.length > 0 && (
        <TabBarOverflowContext.Provider value>
          <TabBarSection>{overflow}</TabBarSection>
        </TabBarOverflowContext.Provider>
      )}
      {children}
    </div>
  )
}

function TabBarSection({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="tab-bar-section"
      className={cn("flex min-w-0 flex-col", className)}
      {...props}
    />
  )
}

function TabBarSectionTitle({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="tab-bar-section-title"
      className={cn(
        "relative flex h-7 items-center ps-4 text-sm text-muted-foreground before:absolute before:inset-s-0 before:top-1/2 before:size-1.5 before:-translate-y-1/2 before:rounded-full before:bg-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function TabBarLink({
  render,
  isActive = false,
  className,
  ...props
}: useRender.ComponentProps<"a"> &
  React.ComponentProps<"a"> & {
    isActive?: boolean
  }) {
  const { setOpen } = useTabBar()

  return useRender({
    defaultTagName: "a",
    props: mergeProps<"a">(
      {
        "aria-current": isActive ? "page" : undefined,
        onClick: () => setOpen(false),
        className: cn(tabBarLinkClassName, className),
      },
      props
    ),
    render,
    state: {
      slot: "tab-bar-link",
      active: isActive,
    },
  })
}

export {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarLink,
  TabBarList,
  TabBarSection,
  TabBarSectionTitle,
  TabBarTrigger,
  useTabBar,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { TabBar, TabBarList, TabBarItem, TabBarTrigger, TabBarContent, TabBarSection, TabBarSectionTitle, TabBarLink } from "@/components/ui/tab-bar"
```

```tsx
<TabBarExample />
```

Use `defaultOpen` on `TabBar` for an uncontrolled bar, or `open` with `onOpenChange` to control it. Give the `nav` a name with `aria-label`.

Mark the current destination with `isActive` on a `TabBarItem` or `TabBarLink`. A `TabBarItem` can render as a link or router link. Choosing a `TabBarLink` or an overflowed item closes the bar. Child components can read the bar's state with the `useTabBar` hook.

Use the `render` prop to render a `TabBarItem` as a link or router link.

## Examples

### Open

Renders with the sections expanded on mount; an outside click closes them.

**Example**

```tsx
import * as React from "react"
import {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarLink,
  TabBarList,
  TabBarSection,
  TabBarSectionTitle,
  TabBarTrigger,
} from "@/components/ui/tab-bar"

const defaultItems = ["Home", "Nominees", "Directory", "Collections"]

const sections = [
  { title: "Awards", links: ["Winners", "Site of the Day", "Nominees"] },
  { title: "Inspiration", links: ["Collections", "Elements", "Resources"] },
  { title: "Directory", links: ["Professionals", "Agencies", "Freelancers"] },
  { title: "Market", links: ["Jobs", "New Events", "Products"] },
]

function TabBarExample({
  defaultOpen,
  items = defaultItems,
  withSections = true,
}: {
  defaultOpen?: boolean
  items?: string[]
  withSections?: boolean
}) {
  const [value, setValue] = React.useState("Home")

  return (
    <div className="h-full min-h-svh bg-background">
      <Backdrop />
      <TabBar aria-label="Main" defaultOpen={defaultOpen}>
        <TabBarList>
          {items.map((item) => (
            <TabBarItem
              key={item}
              isActive={value === item}
              onClick={() => setValue(item)}
            >
              {item}
            </TabBarItem>
          ))}
        </TabBarList>
        <TabBarContent>
          {withSections &&
            sections.map((section) => (
              <TabBarSection key={section.title}>
                <TabBarSectionTitle>{section.title}</TabBarSectionTitle>
                {section.links.map((link) => (
                  <TabBarLink
                    key={link}
                    href={`#${link.toLowerCase().replaceAll(" ", "-")}`}
                    isActive={value === link}
                    onClick={() => setValue(link)}
                  >
                    {link}
                  </TabBarLink>
                ))}
              </TabBarSection>
            ))}
        </TabBarContent>
        <TabBarTrigger>More</TabBarTrigger>
      </TabBar>
    </div>
  )
}

function Backdrop() {
  return (
    <div className="grid grid-cols-2 gap-3 p-4 md:grid-cols-4">
      {Array.from({ length: 16 }, (_, index) => `Card ${index + 1}`).map(
        (card) => (
          <div
            key={card}
            className="flex aspect-[4/3] items-end rounded-2xl bg-primary/15 p-4 text-sm font-semibold text-primary odd:bg-secondary odd:text-foreground"
          >
            {card}
          </div>
        )
      )}
    </div>
  )
}

export default function TabBarOpen() {
  return (
    <div className="h-[32rem] transform-gpu overflow-hidden">
      <TabBarExample defaultOpen />
    </div>
  )
}
```

### Fits without more

Hides the More button because every item fits and there are no sections.

**Example**

```tsx
import * as React from "react"
import {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarLink,
  TabBarList,
  TabBarSection,
  TabBarSectionTitle,
  TabBarTrigger,
} from "@/components/ui/tab-bar"

const defaultItems = ["Home", "Nominees", "Directory", "Collections"]

const sections = [
  { title: "Awards", links: ["Winners", "Site of the Day", "Nominees"] },
  { title: "Inspiration", links: ["Collections", "Elements", "Resources"] },
  { title: "Directory", links: ["Professionals", "Agencies", "Freelancers"] },
  { title: "Market", links: ["Jobs", "New Events", "Products"] },
]

function TabBarExample({
  defaultOpen,
  items = defaultItems,
  withSections = true,
}: {
  defaultOpen?: boolean
  items?: string[]
  withSections?: boolean
}) {
  const [value, setValue] = React.useState("Home")

  return (
    <div className="h-full min-h-svh bg-background">
      <Backdrop />
      <TabBar aria-label="Main" defaultOpen={defaultOpen}>
        <TabBarList>
          {items.map((item) => (
            <TabBarItem
              key={item}
              isActive={value === item}
              onClick={() => setValue(item)}
            >
              {item}
            </TabBarItem>
          ))}
        </TabBarList>
        <TabBarContent>
          {withSections &&
            sections.map((section) => (
              <TabBarSection key={section.title}>
                <TabBarSectionTitle>{section.title}</TabBarSectionTitle>
                {section.links.map((link) => (
                  <TabBarLink
                    key={link}
                    href={`#${link.toLowerCase().replaceAll(" ", "-")}`}
                    isActive={value === link}
                    onClick={() => setValue(link)}
                  >
                    {link}
                  </TabBarLink>
                ))}
              </TabBarSection>
            ))}
        </TabBarContent>
        <TabBarTrigger>More</TabBarTrigger>
      </TabBar>
    </div>
  )
}

function Backdrop() {
  return (
    <div className="grid grid-cols-2 gap-3 p-4 md:grid-cols-4">
      {Array.from({ length: 16 }, (_, index) => `Card ${index + 1}`).map(
        (card) => (
          <div
            key={card}
            className="flex aspect-[4/3] items-end rounded-2xl bg-primary/15 p-4 text-sm font-semibold text-primary odd:bg-secondary odd:text-foreground"
          >
            {card}
          </div>
        )
      )}
    </div>
  )
}

export default function TabBarFitsWithoutMore() {
  return (
    <div className="h-[32rem] transform-gpu overflow-hidden">
      <TabBarExample items={defaultItems.slice(0, 3)} withSections={false} />
    </div>
  )
}
```

### Overflow into more

Holds more items than fit, so the extra items move behind More.

**Example**

```tsx
import * as React from "react"
import {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarLink,
  TabBarList,
  TabBarSection,
  TabBarSectionTitle,
  TabBarTrigger,
} from "@/components/ui/tab-bar"

const defaultItems = ["Home", "Nominees", "Directory", "Collections"]

const manyItems = [...defaultItems, "Market", "Academy", "Conferences"]

const sections = [
  { title: "Awards", links: ["Winners", "Site of the Day", "Nominees"] },
  { title: "Inspiration", links: ["Collections", "Elements", "Resources"] },
  { title: "Directory", links: ["Professionals", "Agencies", "Freelancers"] },
  { title: "Market", links: ["Jobs", "New Events", "Products"] },
]

function TabBarExample({
  defaultOpen,
  items = defaultItems,
  withSections = true,
}: {
  defaultOpen?: boolean
  items?: string[]
  withSections?: boolean
}) {
  const [value, setValue] = React.useState("Home")

  return (
    <div className="h-full min-h-svh bg-background">
      <Backdrop />
      <TabBar aria-label="Main" defaultOpen={defaultOpen}>
        <TabBarList>
          {items.map((item) => (
            <TabBarItem
              key={item}
              isActive={value === item}
              onClick={() => setValue(item)}
            >
              {item}
            </TabBarItem>
          ))}
        </TabBarList>
        <TabBarContent>
          {withSections &&
            sections.map((section) => (
              <TabBarSection key={section.title}>
                <TabBarSectionTitle>{section.title}</TabBarSectionTitle>
                {section.links.map((link) => (
                  <TabBarLink
                    key={link}
                    href={`#${link.toLowerCase().replaceAll(" ", "-")}`}
                    isActive={value === link}
                    onClick={() => setValue(link)}
                  >
                    {link}
                  </TabBarLink>
                ))}
              </TabBarSection>
            ))}
        </TabBarContent>
        <TabBarTrigger>More</TabBarTrigger>
      </TabBar>
    </div>
  )
}

function Backdrop() {
  return (
    <div className="grid grid-cols-2 gap-3 p-4 md:grid-cols-4">
      {Array.from({ length: 16 }, (_, index) => `Card ${index + 1}`).map(
        (card) => (
          <div
            key={card}
            className="flex aspect-[4/3] items-end rounded-2xl bg-primary/15 p-4 text-sm font-semibold text-primary odd:bg-secondary odd:text-foreground"
          >
            {card}
          </div>
        )
      )}
    </div>
  )
}

export default function TabBarOverflowIntoMore() {
  return (
    <div className="h-[32rem] transform-gpu overflow-hidden">
      <TabBarExample items={manyItems} withSections={false} />
    </div>
  )
}
```

### Links

Renders each item as a link, with the active one marked as the current page.

**Example**

```tsx
import {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarList,
  TabBarTrigger,
} from "@/components/ui/tab-bar"

const defaultItems = ["Home", "Nominees", "Directory", "Collections"]

function Backdrop() {
  return (
    <div className="grid grid-cols-2 gap-3 p-4 md:grid-cols-4">
      {Array.from({ length: 16 }, (_, index) => `Card ${index + 1}`).map(
        (card) => (
          <div
            key={card}
            className="flex aspect-[4/3] items-end rounded-2xl bg-primary/15 p-4 text-sm font-semibold text-primary odd:bg-secondary odd:text-foreground"
          >
            {card}
          </div>
        )
      )}
    </div>
  )
}

export default function TabBarLinks() {
  return (
    <div className="h-[32rem] transform-gpu overflow-hidden">
      <div className="h-full min-h-svh bg-background">
        <Backdrop />
        <TabBar aria-label="Main">
          <TabBarList>
            {defaultItems.map((item, index) => (
              <TabBarItem
                key={item}
                isActive={index === 0}
                render={<a href={`#${item.toLowerCase()}`} />}
              >
                {item}
              </TabBarItem>
            ))}
          </TabBarList>
          <TabBarContent />
          <TabBarTrigger>More</TabBarTrigger>
        </TabBar>
      </div>
    </div>
  )
}
```

### Disabled item

Disables one item in the list.

**Example**

```tsx
import {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarList,
  TabBarTrigger,
} from "@/components/ui/tab-bar"

function Backdrop() {
  return (
    <div className="grid grid-cols-2 gap-3 p-4 md:grid-cols-4">
      {Array.from({ length: 16 }, (_, index) => `Card ${index + 1}`).map(
        (card) => (
          <div
            key={card}
            className="flex aspect-[4/3] items-end rounded-2xl bg-primary/15 p-4 text-sm font-semibold text-primary odd:bg-secondary odd:text-foreground"
          >
            {card}
          </div>
        )
      )}
    </div>
  )
}

export default function TabBarDisabledItem() {
  return (
    <div className="h-[32rem] transform-gpu overflow-hidden">
      <div className="h-full min-h-svh bg-background">
        <Backdrop />
        <TabBar aria-label="Main">
          <TabBarList>
            <TabBarItem isActive>Home</TabBarItem>
            <TabBarItem>Nominees</TabBarItem>
            <TabBarItem disabled>Directory</TabBarItem>
          </TabBarList>
          <TabBarContent />
          <TabBarTrigger>More</TabBarTrigger>
        </TabBar>
      </div>
    </div>
  )
}
```

## API Reference

- `TabBar`
- `TabBarList`
  - `TabBarItem`
- `TabBarContent`
- `TabBarTrigger`

### TabBar

The floating `nav` container that holds the open state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Whether the More panel is open, for controlled use. |
| `defaultOpen` | `boolean` | `false` | Whether the More panel starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the More panel opens or closes. |

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

### TabBarList

The row of primary items; items that don't fit move into the content.

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

### TabBarItem

A primary destination in the list, rendered as a button by default.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | Marks the item as the current page with `aria-current="page"`. |

Also accepts the props of `useRender.ComponentProps<"button">`, `React.ComponentProps<"button">`.

### TabBarTrigger

The More button that expands and collapses the content.

Also accepts the props of `React.ComponentProps<typeof Button>`.

### TabBarContent

The expandable area above the list that holds overflowed items and sections.

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

### TabBarSection

A column of related links in the content.

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

### TabBarSectionTitle

The heading of a section, marked with a small dot.

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

### TabBarLink

A link inside a section that closes the bar when chosen.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | Marks the link as the current page with `aria-current="page"`. |

Also accepts the props of `useRender.ComponentProps<"a">`, `React.ComponentProps<"a">`.

## Accessibility

- `TabBar` is a `navigation` landmark; name it with `aria-label`.
- Active items and links set `aria-current="page"`.
- The More button sets `aria-expanded` and `aria-controls` to point to the content.
- `Escape` closes the open bar and returns focus to the More button. A pointer press outside the bar also closes it.
- While open, the list is `inert`. Items moved into the overflow are hidden from assistive technologies and removed from the tab order in the list, and appear as links in the content instead.
- When the More button isn't needed, it's hidden and removed from the tab order.

## Notes

- `TabBar` sets `data-open`, `data-overflowing` and `data-sections` for styling. The trigger sets `data-idle` when it's hidden, and items and links set `data-active`.
- Parts carry `data-slot` attributes such as `tab-bar`, `tab-bar-list`, `tab-bar-item`, `tab-bar-trigger` and `tab-bar-content`.
- The bar sits above the safe-area inset at the bottom of the screen and uses a `card` background with a thin border and a shadow. The active item gets a `secondary` fill and `primary` text.
- Animations are removed when reduced motion is preferred.
- `useTabBar` throws if it's used outside a `TabBar`.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-tab-bar--default.light.webp)

```tsx
import { ChatCircleIcon, HouseIcon, MagnifyingGlassIcon, UserIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const items = [
  { value: "home", label: "Home", icon: HouseIcon },
  { value: "search", label: "Search", icon: MagnifyingGlassIcon },
  { value: "messages", label: "Messages", icon: ChatCircleIcon, badge: 3 },
  { value: "profile", label: "Profile", icon: UserIcon, badge: true },
]

function Page({
  title,
  children,
}: {
  title: string
  children: React.ReactNode
}) {
  return (
    <View className="-m-4 flex-1 bg-background">
      <View className="flex-1 gap-2 p-4">
        <Text variant="h3">{title}</Text>
        <Text variant="muted">Tap a tab to switch screens.</Text>
      </View>
      {children}
    </View>
  )
}

function TabBarExample({
  badges = true,
  disabled,
}: {
  badges?: boolean
  disabled?: string
}) {
  const [value, setValue] = React.useState("home")
  const current = items.find((item) => item.value === value)
  return (
    <Page title={current?.label ?? ""}>
      <TabBar value={value} onValueChange={setValue}>
        <TabBarList>
          {items.map((item) => (
            <TabBarItem
              key={item.value}
              value={item.value}
              icon={item.icon}
              badge={badges ? item.badge : undefined}
              disabled={item.value === disabled}
            >
              {item.label}
            </TabBarItem>
          ))}
        </TabBarList>
      </TabBar>
    </Page>
  )
}

export function TabBarExampleDemo() {
  return (
    <TabBarExample />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TabBarEntry {
  let value: String
  let label: String
  let icon: Image
  let activeIcon: Image
  let badge: TabBarBadge?
}

let tabBarEntries = [
  TabBarEntry(value: "home", label: "Home", icon: Ph.house.regular, activeIcon: Ph.house.fill, badge: nil),
  TabBarEntry(value: "search", label: "Search", icon: Ph.magnifyingGlass.regular, activeIcon: Ph.magnifyingGlass.fill, badge: nil),
  TabBarEntry(value: "messages", label: "Messages", icon: Ph.chatCircle.regular, activeIcon: Ph.chatCircle.fill, badge: 3),
  TabBarEntry(value: "profile", label: "Profile", icon: Ph.user.regular, activeIcon: Ph.user.fill, badge: .dot),
]

struct TabBarPage<Bar: View>: View {
  let title: String
  @ViewBuilder let bar: () -> Bar

  var body: some View {
    VStack(spacing: 0) {
      VStack(alignment: .leading, spacing: 8) {
        Text(verbatim: title).textVariant(.h3)
        Text("Tap a tab to switch screens.").textVariant(.muted)
      }
      .padding(16)
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
      bar()
    }
    .background(EsuissColor.background.ignoresSafeArea())
  }
}

struct TabBarExample: View {
  var badges = true
  var disabled: String?

  @State private var value = "home"

  var body: some View {
    TabBarPage(title: tabBarEntries.first { $0.value == value }?.label ?? "") {
      TabBar(selection: $value) {
        ForEach(tabBarEntries, id: \.value) { item in
          TabBarItem(
            verbatim: item.label,
            value: item.value,
            icon: item.icon,
            activeIcon: item.activeIcon,
            badge: badges ? item.badge : nil
          )
          .disabled(item.value == disabled)
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.House
import com.adamglin.phosphoricons.fill.MagnifyingGlass
import com.adamglin.phosphoricons.fill.User
import com.adamglin.phosphoricons.regular.ChatCircle
import com.adamglin.phosphoricons.regular.House
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.User
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

class TabBarEntry(val value: String, val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val badge: String? = null)

val tabBarEntries = listOf(
    TabBarEntry("home", "Home", PhosphorIcons.Regular.House, PhosphorIcons.Fill.House),
    TabBarEntry("search", "Search", PhosphorIcons.Regular.MagnifyingGlass, PhosphorIcons.Fill.MagnifyingGlass),
    TabBarEntry("messages", "Messages", PhosphorIcons.Regular.ChatCircle, PhosphorIcons.Fill.ChatCircle, "3"),
    TabBarEntry("profile", "Profile", PhosphorIcons.Regular.User, PhosphorIcons.Fill.User, ""),
)

@Composable
fun TabBarPage(title: String, bar: @Composable () -> Unit) {
    Screen(edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier.weight(1f).fillMaxWidth().padding(16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                Text(title, variant = TextVariant.H3)
                Text("Tap a tab to switch screens.", variant = TextVariant.Muted)
            }
            bar()
        }
    }
}

@Composable
fun TabBarExample(badges: Boolean = true, disabled: String? = null) {
    var value by rememberSaveable { mutableStateOf("home") }
    val current = tabBarEntries.firstOrNull { it.value == value }
    TabBarPage(current?.label ?: "") {
        TabBar(value = value, onValueChange = { value = it }) {
            tabBarEntries.forEach { entry ->
                TabBarItem(
                    value = entry.value,
                    label = entry.label,
                    icon = entry.icon,
                    selectedIcon = entry.selectedIcon,
                    badge = if (badges) entry.badge else null,
                    enabled = entry.value != disabled,
                )
            }
        }
    }
}
```

`TabBar` is built on the tabs primitive and sits at the bottom of a screen. `TabBarList` is a rounded capsule with a shadow, inset from the screen edges, so the bar floats above the page. The root adds bottom padding for the safe area, minus any inset an enclosing `Screen` already handles, and never less than 12 points. `TabBarItem` stacks an icon over a short label; the active tab sits on a `bg-secondary` pill and uses `text-primary` and a filled icon weight.

Use it for switching between the top-level sections of an app. For switching views within a screen, use Tabs.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/tab-bar
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add tab-bar
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add tab-bar --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add tab-bar --platform kotlin
```

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-safe-area-context react-native-svg
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, `components/primitives/tabs.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/tab-bar.tsx`:

```tsx title="uniwind/components/ui/tab-bar.tsx"
import { cn } from "cn"
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useHaptics } from "@/components/primitives/haptics"
import * as TabsPrimitive from "@/components/primitives/tabs"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"
import { ScreenContext } from "@/components/ui/screen"
import { Text, TextClassContext } from "@/components/ui/text"

type TabBarBadgeValue = number | string | boolean

function badgeText(badge: TabBarBadgeValue | undefined) {
  if (badge === undefined || badge === false || badge === true) return undefined
  if (typeof badge === "number") {
    if (badge <= 0) return undefined
    return badge > 99 ? "99+" : String(badge)
  }
  return badge.length > 0 ? badge : undefined
}

function TabBar({
  onValueChange,
  className,
  style,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
  const haptics = useHaptics()
  const insets = useSafeAreaInsets()
  const screen = React.useContext(ScreenContext)
  const consumed = screen?.edges.includes("bottom") ? insets.bottom : 0
  const paddingBottom = Math.max(12, insets.bottom - consumed - 8)
  return (
    <TabsPrimitive.Root
      className={(state) => cn("px-4 pt-2", resolveClassName(className, state))}
      style={(state) => [
        { paddingBottom },
        typeof style === "function" ? style(state) : style,
      ]}
      onValueChange={(value, details) => {
        onValueChange?.(value, details)
        if (!details.isCanceled) haptics.selection()
      }}
      {...props}
    />
  )
}

function TabBarList({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
  return (
    <TabsPrimitive.List
      className={(state) =>
        cn(
          "flex-row rounded-full border border-foreground/5 bg-card p-1 shadow-lg dark:border-foreground/10",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function TabBarBadge({ badge }: { badge: TabBarBadgeValue }) {
  const text = badgeText(badge)
  if (badge === true) {
    return (
      <View className="absolute -top-0.5 -end-1 size-2.5 rounded-full border-2 border-card bg-destructive" />
    )
  }
  if (text === undefined) return null
  return (
    <View className="absolute -top-1.5 -end-3 h-4.5 min-w-4.5 items-center justify-center rounded-full border-2 border-card bg-destructive px-1">
      <Text
        className="text-xs font-semibold text-primary-foreground"
        numberOfLines={1}
      >
        {text}
      </Text>
    </View>
  )
}

function TabBarItem({
  icon,
  badge,
  className,
  children,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Tab> & {
  icon?: PhosphorIcon
  badge?: TabBarBadgeValue
}) {
  const text = badgeText(badge)
  return (
    <TabsPrimitive.Tab
      className={(state) =>
        cn(
          "flex-1 items-center justify-center gap-0.5 rounded-full py-1.5 active:opacity-80",
          state.active && "bg-secondary",
          resolveClassName(className, state)
        )
      }
      accessibilityValue={text === undefined ? undefined : { text }}
      render={(renderProps, state) => (
        <TextClassContext.Provider
          value={cn(
            "text-xs font-medium",
            state.active && "text-primary",
            !state.active && state.disabled && "opacity-50",
            !(state.active || state.disabled) && "text-muted-foreground"
          )}
        >
          <Pressable {...renderProps}>
            {icon || badge !== undefined ? (
              <View className="relative">
                {icon ? (
                  <Icon
                    as={icon}
                    weight={state.active ? "fill" : "regular"}
                    className="size-6"
                  />
                ) : null}
                {badge !== undefined ? <TabBarBadge badge={badge} /> : null}
              </View>
            ) : null}
            {typeof children === "string" || typeof children === "number" ? (
              <Text numberOfLines={1}>{children}</Text>
            ) : (
              children
            )}
          </Pressable>
        </TextClassContext.Provider>
      )}
      {...props}
    />
  )
}

export type { TabBarBadgeValue }
export { TabBar, TabBarItem, TabBarList }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-safe-area-context react-native-svg
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, `components/primitives/tabs.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/tab-bar.tsx`:

```tsx title="nativewind/components/ui/tab-bar.tsx"
import { cn } from "cn"
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useHaptics } from "@/components/primitives/haptics"
import * as TabsPrimitive from "@/components/primitives/tabs"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"
import { ScreenContext } from "@/components/ui/screen"
import { Text, TextClassContext } from "@/components/ui/text"

type TabBarBadgeValue = number | string | boolean

function badgeText(badge: TabBarBadgeValue | undefined) {
  if (badge === undefined || badge === false || badge === true) return undefined
  if (typeof badge === "number") {
    if (badge <= 0) return undefined
    return badge > 99 ? "99+" : String(badge)
  }
  return badge.length > 0 ? badge : undefined
}

function TabBar({
  onValueChange,
  className,
  style,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
  const haptics = useHaptics()
  const insets = useSafeAreaInsets()
  const screen = React.useContext(ScreenContext)
  const consumed = screen?.edges.includes("bottom") ? insets.bottom : 0
  const paddingBottom = Math.max(12, insets.bottom - consumed - 8)
  return (
    <TabsPrimitive.Root
      className={(state) => cn("px-4 pt-2", resolveClassName(className, state))}
      style={(state) => [
        { paddingBottom },
        typeof style === "function" ? style(state) : style,
      ]}
      onValueChange={(value, details) => {
        onValueChange?.(value, details)
        if (!details.isCanceled) haptics.selection()
      }}
      {...props}
    />
  )
}

function TabBarList({
  className,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
  return (
    <TabsPrimitive.List
      className={(state) =>
        cn(
          "flex-row rounded-full border border-foreground-5 bg-card p-1 shadow-lg dark:border-foreground-10",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function TabBarBadge({ badge }: { badge: TabBarBadgeValue }) {
  const text = badgeText(badge)
  if (badge === true) {
    return (
      <View className="absolute -top-0.5 -end-1 size-2.5 rounded-full border-2 border-card bg-destructive" />
    )
  }
  if (text === undefined) return null
  return (
    <View className="absolute -top-1.5 -end-3 h-[1.125rem] min-w-[1.125rem] items-center justify-center rounded-full border-2 border-card bg-destructive px-1">
      <Text
        className="text-xs font-semibold text-primary-foreground"
        numberOfLines={1}
      >
        {text}
      </Text>
    </View>
  )
}

function TabBarItem({
  icon,
  badge,
  className,
  children,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Tab> & {
  icon?: PhosphorIcon
  badge?: TabBarBadgeValue
}) {
  const text = badgeText(badge)
  return (
    <TabsPrimitive.Tab
      className={(state) =>
        cn(
          "flex-1 items-center justify-center gap-0.5 rounded-full py-1.5 active:opacity-80",
          state.active && "bg-secondary",
          resolveClassName(className, state)
        )
      }
      accessibilityValue={text === undefined ? undefined : { text }}
      render={(renderProps, state) => (
        <TextClassContext.Provider
          value={cn(
            "text-xs font-medium",
            state.active && "text-primary",
            !state.active && state.disabled && "opacity-50",
            !(state.active || state.disabled) && "text-muted-foreground"
          )}
        >
          <Pressable {...renderProps}>
            {icon || badge !== undefined ? (
              <View className="relative">
                {icon ? (
                  <Icon
                    as={icon}
                    weight={state.active ? "fill" : "regular"}
                    className="size-6"
                  />
                ) : null}
                {badge !== undefined ? <TabBarBadge badge={badge} /> : null}
              </View>
            ) : null}
            {typeof children === "string" || typeof children === "number" ? (
              <Text numberOfLines={1}>{children}</Text>
            ) : (
              children
            )}
          </Pressable>
        </TextClassContext.Provider>
      )}
      {...props}
    />
  )
}

export type { TabBarBadgeValue }
export { TabBar, TabBarItem, TabBarList }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-safe-area-context react-native-svg
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/haptics.tsx`, `components/primitives/tabs.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/tab-bar.tsx`:

```tsx title="stylesheet/components/ui/tab-bar.tsx"
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, View, type ViewStyle } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useHaptics } from "@/components/primitives/haptics"
import * as TabsPrimitive from "@/components/primitives/tabs"
import { resolveStyle } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"
import { ScreenContext } from "@/components/ui/screen"
import { Text, TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

type TabBarBadgeValue = number | string | boolean

const styles = StyleSheet.create({
  root: { paddingHorizontal: 16, paddingTop: 8 },
  list: {
    flexDirection: "row",
    borderRadius: radius.full,
    borderWidth: 1,
    padding: 4,
    ...shadow.lg,
  },
  item: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    gap: 2,
    borderRadius: radius.full,
    paddingVertical: 6,
  },
  label: { ...fontSize.xs, fontWeight: "500" },
  iconWrap: { position: "relative" },
  dot: {
    position: "absolute",
    top: -2,
    end: -4,
    width: 10,
    height: 10,
    borderRadius: radius.full,
    borderWidth: 2,
  },
  badge: {
    position: "absolute",
    top: -6,
    end: -12,
    height: 18,
    minWidth: 18,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    borderWidth: 2,
    paddingHorizontal: 4,
  },
  badgeText: { ...fontSize.xs, fontWeight: "600" },
})

function withAlpha(hex: string, alpha: number) {
  const value = Number.parseInt(hex.slice(1), 16)
  return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`
}

function badgeText(badge: TabBarBadgeValue | undefined) {
  if (badge === undefined || badge === false || badge === true) return undefined
  if (typeof badge === "number") {
    if (badge <= 0) return undefined
    return badge > 99 ? "99+" : String(badge)
  }
  return badge.length > 0 ? badge : undefined
}

function TabBar({
  onValueChange,
  style,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
  const haptics = useHaptics()
  const insets = useSafeAreaInsets()
  const screen = React.useContext(ScreenContext)
  const consumed = screen?.edges.includes("bottom") ? insets.bottom : 0
  const paddingBottom = Math.max(12, insets.bottom - consumed - 8)
  return (
    <TabsPrimitive.Root
      style={(state) => [
        styles.root,
        { paddingBottom },
        resolveStyle(style, state) as ViewStyle,
      ]}
      onValueChange={(value, details) => {
        onValueChange?.(value, details)
        if (!details.isCanceled) haptics.selection()
      }}
      {...props}
    />
  )
}

function TabBarList({
  style,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
  const { colors, isDark } = useTheme()
  return (
    <TabsPrimitive.List
      style={(state) => [
        styles.list,
        {
          borderColor: withAlpha(colors.foreground, isDark ? 0.1 : 0.05),
          backgroundColor: colors.card,
        },
        resolveStyle(style, state) as ViewStyle,
      ]}
      {...props}
    />
  )
}

function TabBarBadge({ badge }: { badge: TabBarBadgeValue }) {
  const { colors } = useTheme()
  const text = badgeText(badge)
  if (badge === true) {
    return (
      <View
        style={[
          styles.dot,
          { backgroundColor: colors.destructive, borderColor: colors.card },
        ]}
      />
    )
  }
  if (text === undefined) return null
  return (
    <View
      style={[
        styles.badge,
        { backgroundColor: colors.destructive, borderColor: colors.card },
      ]}
    >
      <Text
        style={[styles.badgeText, { color: colors.primaryForeground }]}
        numberOfLines={1}
      >
        {text}
      </Text>
    </View>
  )
}

function TabBarItem({
  icon,
  badge,
  style,
  children,
  ...props
}: React.ComponentProps<typeof TabsPrimitive.Tab> & {
  icon?: PhosphorIcon
  badge?: TabBarBadgeValue
}) {
  const { colors } = useTheme()
  const text = badgeText(badge)
  return (
    <TabsPrimitive.Tab
      style={(state) => [
        styles.item,
        state.active && { backgroundColor: colors.secondary },
        state.disabled && { opacity: 0.5 },
        resolveStyle(style, state) as ViewStyle,
      ]}
      accessibilityValue={text === undefined ? undefined : { text }}
      render={(renderProps, state) => {
        let color = colors.mutedForeground
        if (state.active) color = colors.primary
        const { style: baseStyle, ...rest } = renderProps
        return (
          <TextStyleContext.Provider value={{ ...styles.label, color }}>
            <Pressable
              {...rest}
              style={({ pressed }) => [baseStyle, pressed && { opacity: 0.8 }]}
            >
              {icon || badge !== undefined ? (
                <View style={styles.iconWrap}>
                  {icon ? (
                    <Icon
                      as={icon}
                      size={24}
                      color={color}
                      weight={state.active ? "fill" : "regular"}
                    />
                  ) : null}
                  {badge !== undefined ? <TabBarBadge badge={badge} /> : null}
                </View>
              ) : null}
              {typeof children === "string" || typeof children === "number" ? (
                <Text numberOfLines={1}>{children}</Text>
              ) : (
                children
              )}
            </Pressable>
          </TextStyleContext.Provider>
        )
      }}
      {...props}
    />
  )
}

export type { TabBarBadgeValue }
export { TabBar, TabBarItem, TabBarList }
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/TabBar.swift"
import SwiftUI

public enum TabBarBadge: Equatable, Sendable, ExpressibleByIntegerLiteral, ExpressibleByStringLiteral {
  case dot
  case count(Int)
  case text(String)

  public init(integerLiteral value: Int) {
    self = .count(value)
  }

  public init(stringLiteral value: String) {
    self = .text(value)
  }

  var label: String? {
    switch self {
    case .dot: nil
    case .count(let value): value > 99 ? "99+" : "\(value)"
    case .text(let value): value
    }
  }

  var isVisible: Bool {
    switch self {
    case .dot: true
    case .count(let value): value > 0
    case .text(let value): !value.isEmpty
    }
  }
}

struct TabBarContext: @unchecked Sendable {
  var selected: AnyHashable?
  var namespace: Namespace.ID?
  var select: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}

extension EnvironmentValues {
  @Entry var tabBar = TabBarContext()
}

public struct TabBar<Content: View>: View {
  let selection: Binding<AnyHashable>
  let content: Content

  @Namespace private var namespace
  @State private var bottomInset: CGFloat = 0
  @Environment(\.colorScheme) private var colorScheme
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init<Value: Hashable & Sendable>(
    selection: Binding<Value>,
    @ViewBuilder content: () -> Content
  ) {
    self.selection = Binding(
      get: { AnyHashable(selection.wrappedValue) },
      set: { value in
        if let value = value.base as? Value {
          selection.wrappedValue = value
        }
      }
    )
    self.content = content()
  }

  public var body: some View {
    let binding = selection
    let context = TabBarContext(selected: binding.wrappedValue, namespace: namespace) { value in
      if binding.wrappedValue != value { binding.wrappedValue = value }
    }
    HStack(alignment: .top, spacing: 0) {
      content
    }
    .padding(4)
    .background {
      Capsule()
        .fill(EsuissColor.card)
        .shadow(.lg)
    }
    .overlay {
      Capsule().strokeBorder(EsuissColor.foreground.opacity(colorScheme == .dark ? 0.1 : 0.05))
    }
    .animation(reduceMotion ? nil : .spring(duration: 0.3, bounce: 0.15), value: binding.wrappedValue)
    .padding(.horizontal, 16)
    .padding(.top, 8)
    .padding(.bottom, max(12, bottomInset - 8))
    .frame(maxWidth: .infinity)
    .ignoresSafeArea(.container, edges: .bottom)
    .background {
      Color.clear
        .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.bottom.rounded() } action: { bottomInset = $0 }
    }
    .environment(\.tabBar, context)
    .hapticFeedback(.selection, trigger: binding.wrappedValue)
    .accessibilityElement(children: .contain)
    .accessibilityAddTraits(.isTabBar)
  }
}

public struct TabBarItem: View {
  let value: AnyHashable
  let title: Text
  let icon: Image
  let activeIcon: Image?
  let badge: TabBarBadge?

  @Environment(\.tabBar) private var tabBar
  @Environment(\.isEnabled) private var isEnabled

  public init<Value: Hashable & Sendable>(
    _ title: LocalizedStringKey,
    value: Value,
    icon: Image,
    activeIcon: Image? = nil,
    badge: TabBarBadge? = nil
  ) {
    self.value = AnyHashable(value)
    self.title = Text(title)
    self.icon = icon
    self.activeIcon = activeIcon
    self.badge = badge
  }

  public init<Value: Hashable & Sendable>(
    verbatim title: String,
    value: Value,
    icon: Image,
    activeIcon: Image? = nil,
    badge: TabBarBadge? = nil
  ) {
    self.value = AnyHashable(value)
    self.title = Text(verbatim: title)
    self.icon = icon
    self.activeIcon = activeIcon
    self.badge = badge
  }

  public var body: some View {
    let active = tabBar.selected == value
    Button {
      tabBar.select(value)
    } label: {
      VStack(spacing: 2) {
        (active ? activeIcon ?? icon : icon)
          .resizable()
          .scaledToFit()
          .frame(width: 24, height: 24)
          .overlay(alignment: .topLeading) {
            if let badge, badge.isVisible {
              TabBarBadgeView(badge: badge)
                .offset(x: badge.label == nil ? 16 : 14, y: badge.label == nil ? -1 : -6)
            }
          }
        title
          .scaledFont(.xs, weight: .medium)
          .lineLimit(1)
      }
      .foregroundStyle(active ? EsuissColor.primary : EsuissColor.mutedForeground)
      .opacity(isEnabled ? 1 : 0.5)
      .padding(.vertical, 6)
      .frame(maxWidth: .infinity)
      .contentShape(Capsule())
    }
    .buttonStyle(TabBarItemStyle())
    .background {
      if active, let namespace = tabBar.namespace {
        Capsule()
          .fill(EsuissColor.secondary)
          .matchedGeometryEffect(id: "active", in: namespace)
      }
    }
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(title)
    .accessibilityValue(badgeValue)
    .accessibilityAddTraits(active ? [.isSelected, .isButton] : .isButton)
  }

  private var badgeValue: Text {
    guard let badge, badge.isVisible else { return Text(verbatim: "") }
    switch badge {
    case .dot: return Text("New")
    case .count(let value): return Text("\(value) new")
    case .text(let value): return Text(verbatim: value)
    }
  }
}

private struct TabBarItemStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

private struct TabBarBadgeView: View {
  let badge: TabBarBadge

  var body: some View {
    Group {
      if let label = badge.label {
        Text(verbatim: label)
          .scaledFont(TextStyle.xs, weight: .semibold)
          .monospacedDigit()
          .foregroundStyle(EsuissColor.primaryForeground)
          .padding(.horizontal, 5)
          .frame(minWidth: 18, minHeight: 18)
          .background(EsuissColor.destructive, in: Capsule())
      } else {
        Circle()
          .fill(EsuissColor.destructive)
          .frame(width: 10, height: 10)
      }
    }
    .background(Capsule().fill(EsuissColor.card).padding(-2))
    .fixedSize()
    .accessibilityHidden(true)
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/TabBar.kt"
package com.esuiss.ui.components

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.launch

@Immutable
private class TabBarIndicator(val x: Float, val y: Float, val width: Float, val height: Float)

@Stable
private class TabBarLayout {
    val items = mutableStateMapOf<String, TabBarIndicator>()
}

@Immutable
private class TabBarContext(
    val value: String,
    val onValueChange: (String) -> Unit,
    val layout: TabBarLayout,
)

private val LocalTabBar = staticCompositionLocalOf<TabBarContext?> { null }

@Composable
fun TabBar(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val density = LocalDensity.current
    var consumed by remember { mutableStateOf(WindowInsets(0, 0, 0, 0)) }
    val remaining = with(density) { WindowInsets.safeDrawing.exclude(consumed).getBottom(density).toDp() }
    val bottom = (remaining - 8.dp).coerceAtLeast(12.dp)
    val layout = remember { TabBarLayout() }
    val x = remember { Animatable(0f) }
    val width = remember { Animatable(0f) }
    var placed by remember { mutableStateOf(false) }
    val target = layout.items[value]
    LaunchedEffect(target?.x, target?.width) {
        if (target == null) return@LaunchedEffect
        if (!placed) {
            x.snapTo(target.x)
            width.snapTo(target.width)
            placed = true
            return@LaunchedEffect
        }
        val motion = spring<Float>(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow)
        launch { x.animateTo(target.x, motion) }
        launch { width.animateTo(target.width, motion) }
    }
    CompositionLocalProvider(LocalTabBar provides TabBarContext(value, onValueChange, layout)) {
        Column(
            modifier = modifier
                .fillMaxWidth()
                .onConsumedWindowInsetsChanged { consumed = it }
                .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
                .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottom),
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .raisedSurface(colors, CircleShape)
                    .padding(4.dp)
                    .drawBehind {
                        val current = layout.items[value]
                        if (placed && current != null) {
                            drawRoundRect(
                                color = colors.secondary,
                                topLeft = Offset(x.value, current.y),
                                size = Size(width.value, current.height),
                                cornerRadius = CornerRadius(current.height / 2f),
                            )
                        }
                    }
                    .selectableGroup(),
                verticalAlignment = Alignment.CenterVertically,
                content = content,
            )
        }
    }
}

@Composable
fun RowScope.TabBarItem(
    value: String,
    label: String,
    icon: ImageVector,
    modifier: Modifier = Modifier,
    selectedIcon: ImageVector? = null,
    badge: String? = null,
    enabled: Boolean = true,
) {
    val context = checkNotNull(LocalTabBar.current) { "TabBarItem must be used within a TabBar" }
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val selected = context.value == value
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val foreground = if (selected) colors.primary else colors.mutedForeground
    DisposableEffect(context.layout, value) {
        onDispose { context.layout.items.remove(value) }
    }
    Column(
        modifier = modifier
            .weight(1f)
            .onPlaced { coordinates ->
                val position = coordinates.positionInParent()
                val next = TabBarIndicator(position.x, position.y, coordinates.size.width.toFloat(), coordinates.size.height.toFloat())
                val previous = context.layout.items[value]
                if (previous == null || previous.x != next.x || previous.y != next.y || previous.width != next.width || previous.height != next.height) {
                    context.layout.items[value] = next
                }
            }
            .alpha(
                when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                },
            )
            .clip(CircleShape)
            .selectable(
                selected = selected,
                interactionSource = interaction,
                indication = null,
                enabled = enabled,
                role = Role.Tab,
            ) {
                if (!selected) haptics.selection()
                context.onValueChange(value)
            }
            .padding(vertical = 6.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
    ) {
        Box {
            Icon(if (selected && selectedIcon != null) selectedIcon else icon, contentDescription = null, size = 24.dp, tint = foreground)
            if (badge != null) TabBarBadge(badge, Modifier.align(Alignment.TopStart).offset(x = if (badge.isEmpty()) 18.dp else 14.dp, y = (-4).dp))
        }
        BasicText(
            label,
            style = Typography.xs.copy(color = foreground, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

@Composable
private fun TabBarBadge(text: String, modifier: Modifier) {
    val colors = EsuissTheme.colors
    if (text.isEmpty()) {
        Box(
            modifier = modifier
                .size(10.dp)
                .clip(CircleShape)
                .background(colors.destructive, CircleShape)
                .border(2.dp, colors.card, CircleShape),
        )
        return
    }
    Box(
        modifier = modifier
            .heightIn(min = 18.dp)
            .widthIn(min = 18.dp)
            .clip(CircleShape)
            .background(colors.destructive, CircleShape)
            .border(2.dp, colors.card, CircleShape)
            .padding(horizontal = 5.dp),
        contentAlignment = Alignment.Center,
    ) {
        BasicText(
            text,
            style = Typography.xs.copy(color = colors.primaryForeground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
            maxLines = 1,
        )
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { TabBar, TabBarList, TabBarItem } from "@/components/ui/tab-bar"
```

```tsx
<TabBarExample />
```

Put `TabBarItem` entries inside a `TabBarList`, inside `TabBar`. Give each item a `value`, an `icon` and a label as children. Control the active tab with `value` and `onValueChange`, or set `defaultValue`.

The `badge` prop shows a count, text or a dot: numbers above 99 show as `99+`, `0` and empty strings show nothing, and `true` shows a small dot. A selection haptic plays whenever the tab changes, when a haptics adapter is provided through `HapticsContext`.

The `TabBarBadgeValue` type is also exported.

## Examples

### Without badges

The tab bar without badges.

![Preview](https://ui.suiss.app/previews/components-tab-bar--without-badges.light.webp)

```tsx
import { ChatCircleIcon, HouseIcon, MagnifyingGlassIcon, UserIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const items = [
  { value: "home", label: "Home", icon: HouseIcon },
  { value: "search", label: "Search", icon: MagnifyingGlassIcon },
  { value: "messages", label: "Messages", icon: ChatCircleIcon, badge: 3 },
  { value: "profile", label: "Profile", icon: UserIcon, badge: true },
]

function Page({
  title,
  children,
}: {
  title: string
  children: React.ReactNode
}) {
  return (
    <View className="-m-4 flex-1 bg-background">
      <View className="flex-1 gap-2 p-4">
        <Text variant="h3">{title}</Text>
        <Text variant="muted">Tap a tab to switch screens.</Text>
      </View>
      {children}
    </View>
  )
}

function TabBarExample({
  badges = true,
  disabled,
}: {
  badges?: boolean
  disabled?: string
}) {
  const [value, setValue] = React.useState("home")
  const current = items.find((item) => item.value === value)
  return (
    <Page title={current?.label ?? ""}>
      <TabBar value={value} onValueChange={setValue}>
        <TabBarList>
          {items.map((item) => (
            <TabBarItem
              key={item.value}
              value={item.value}
              icon={item.icon}
              badge={badges ? item.badge : undefined}
              disabled={item.value === disabled}
            >
              {item.label}
            </TabBarItem>
          ))}
        </TabBarList>
      </TabBar>
    </Page>
  )
}

export function TabBarWithoutBadgesExample() {
  return (
    <TabBarExample badges={false} />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TabBarWithoutBadgesExample: View {
  var body: some View {
    TabBarExample(badges: false)
  }
}

struct TabBarEntry {
  let value: String
  let label: String
  let icon: Image
  let activeIcon: Image
  let badge: TabBarBadge?
}

let tabBarEntries = [
  TabBarEntry(value: "home", label: "Home", icon: Ph.house.regular, activeIcon: Ph.house.fill, badge: nil),
  TabBarEntry(value: "search", label: "Search", icon: Ph.magnifyingGlass.regular, activeIcon: Ph.magnifyingGlass.fill, badge: nil),
  TabBarEntry(value: "messages", label: "Messages", icon: Ph.chatCircle.regular, activeIcon: Ph.chatCircle.fill, badge: 3),
  TabBarEntry(value: "profile", label: "Profile", icon: Ph.user.regular, activeIcon: Ph.user.fill, badge: .dot),
]

struct TabBarPage<Bar: View>: View {
  let title: String
  @ViewBuilder let bar: () -> Bar

  var body: some View {
    VStack(spacing: 0) {
      VStack(alignment: .leading, spacing: 8) {
        Text(verbatim: title).textVariant(.h3)
        Text("Tap a tab to switch screens.").textVariant(.muted)
      }
      .padding(16)
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
      bar()
    }
    .background(EsuissColor.background.ignoresSafeArea())
  }
}

struct TabBarExample: View {
  var badges = true
  var disabled: String?

  @State private var value = "home"

  var body: some View {
    TabBarPage(title: tabBarEntries.first { $0.value == value }?.label ?? "") {
      TabBar(selection: $value) {
        ForEach(tabBarEntries, id: \.value) { item in
          TabBarItem(
            verbatim: item.label,
            value: item.value,
            icon: item.icon,
            activeIcon: item.activeIcon,
            badge: badges ? item.badge : nil
          )
          .disabled(item.value == disabled)
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.House
import com.adamglin.phosphoricons.fill.MagnifyingGlass
import com.adamglin.phosphoricons.fill.User
import com.adamglin.phosphoricons.regular.ChatCircle
import com.adamglin.phosphoricons.regular.House
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.User
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun TabBarWithoutBadgesExample() {
    TabBarExample(badges = false)
}

class TabBarEntry(val value: String, val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val badge: String? = null)

val tabBarEntries = listOf(
    TabBarEntry("home", "Home", PhosphorIcons.Regular.House, PhosphorIcons.Fill.House),
    TabBarEntry("search", "Search", PhosphorIcons.Regular.MagnifyingGlass, PhosphorIcons.Fill.MagnifyingGlass),
    TabBarEntry("messages", "Messages", PhosphorIcons.Regular.ChatCircle, PhosphorIcons.Fill.ChatCircle, "3"),
    TabBarEntry("profile", "Profile", PhosphorIcons.Regular.User, PhosphorIcons.Fill.User, ""),
)

@Composable
fun TabBarPage(title: String, bar: @Composable () -> Unit) {
    Screen(edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier.weight(1f).fillMaxWidth().padding(16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                Text(title, variant = TextVariant.H3)
                Text("Tap a tab to switch screens.", variant = TextVariant.Muted)
            }
            bar()
        }
    }
}

@Composable
fun TabBarExample(badges: Boolean = true, disabled: String? = null) {
    var value by rememberSaveable { mutableStateOf("home") }
    val current = tabBarEntries.firstOrNull { it.value == value }
    TabBarPage(current?.label ?: "") {
        TabBar(value = value, onValueChange = { value = it }) {
            tabBarEntries.forEach { entry ->
                TabBarItem(
                    value = entry.value,
                    label = entry.label,
                    icon = entry.icon,
                    selectedIcon = entry.selectedIcon,
                    badge = if (badges) entry.badge else null,
                    enabled = entry.value != disabled,
                )
            }
        }
    }
}
```

### Large badge

A count above 99 shown as `99+` and a text badge.

![Preview](https://ui.suiss.app/previews/components-tab-bar--large-badge.light.webp)

```tsx
import { ChatCircleIcon, GearSixIcon, HouseIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

function Page({
  title,
  children,
}: {
  title: string
  children: React.ReactNode
}) {
  return (
    <View className="-m-4 flex-1 bg-background">
      <View className="flex-1 gap-2 p-4">
        <Text variant="h3">{title}</Text>
        <Text variant="muted">Tap a tab to switch screens.</Text>
      </View>
      {children}
    </View>
  )
}

export function TabBarLargeBadgeExample() {
  return (
    <Page title="Home">
      <TabBar defaultValue="home">
        <TabBarList>
          <TabBarItem value="home" icon={HouseIcon}>
            Home
          </TabBarItem>
          <TabBarItem value="messages" icon={ChatCircleIcon} badge={128}>
            Messages
          </TabBarItem>
          <TabBarItem value="settings" icon={GearSixIcon} badge="New">
            Settings
          </TabBarItem>
        </TabBarList>
      </TabBar>
    </Page>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TabBarPage<Bar: View>: View {
  let title: String
  @ViewBuilder let bar: () -> Bar

  var body: some View {
    VStack(spacing: 0) {
      VStack(alignment: .leading, spacing: 8) {
        Text(verbatim: title).textVariant(.h3)
        Text("Tap a tab to switch screens.").textVariant(.muted)
      }
      .padding(16)
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
      bar()
    }
    .background(EsuissColor.background.ignoresSafeArea())
  }
}

struct LargeBadgeDemo: View {
  @State private var value = "home"

  var body: some View {
    TabBarPage(title: "Home") {
      TabBar(selection: $value) {
        TabBarItem("Home", value: "home", icon: Ph.house.regular, activeIcon: Ph.house.fill)
        TabBarItem("Messages", value: "messages", icon: Ph.chatCircle.regular, activeIcon: Ph.chatCircle.fill, badge: 128)
        TabBarItem("Settings", value: "settings", icon: Ph.gearSix.regular, activeIcon: Ph.gearSix.fill, badge: "New")
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.GearSix
import com.adamglin.phosphoricons.fill.House
import com.adamglin.phosphoricons.regular.ChatCircle
import com.adamglin.phosphoricons.regular.GearSix
import com.adamglin.phosphoricons.regular.House
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun TabBarPage(title: String, bar: @Composable () -> Unit) {
    Screen(edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier.weight(1f).fillMaxWidth().padding(16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                Text(title, variant = TextVariant.H3)
                Text("Tap a tab to switch screens.", variant = TextVariant.Muted)
            }
            bar()
        }
    }
}

@Composable
fun TabBarLargeBadge() {
    var value by rememberSaveable { mutableStateOf("home") }
    TabBarPage("Home") {
        TabBar(value = value, onValueChange = { value = it }) {
            TabBarItem(value = "home", label = "Home", icon = PhosphorIcons.Regular.House, selectedIcon = PhosphorIcons.Fill.House)
            TabBarItem(value = "messages", label = "Messages", icon = PhosphorIcons.Regular.ChatCircle, selectedIcon = PhosphorIcons.Fill.ChatCircle, badge = "128")
            TabBarItem(value = "settings", label = "Settings", icon = PhosphorIcons.Regular.GearSix, selectedIcon = PhosphorIcons.Fill.GearSix, badge = "New")
        }
    }
}
```

### Disabled item

A tab bar with one disabled tab.

![Preview](https://ui.suiss.app/previews/components-tab-bar--disabled-item.light.webp)

```tsx
import { ChatCircleIcon, HouseIcon, MagnifyingGlassIcon, UserIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const items = [
  { value: "home", label: "Home", icon: HouseIcon },
  { value: "search", label: "Search", icon: MagnifyingGlassIcon },
  { value: "messages", label: "Messages", icon: ChatCircleIcon, badge: 3 },
  { value: "profile", label: "Profile", icon: UserIcon, badge: true },
]

function Page({
  title,
  children,
}: {
  title: string
  children: React.ReactNode
}) {
  return (
    <View className="-m-4 flex-1 bg-background">
      <View className="flex-1 gap-2 p-4">
        <Text variant="h3">{title}</Text>
        <Text variant="muted">Tap a tab to switch screens.</Text>
      </View>
      {children}
    </View>
  )
}

function TabBarExample({
  badges = true,
  disabled,
}: {
  badges?: boolean
  disabled?: string
}) {
  const [value, setValue] = React.useState("home")
  const current = items.find((item) => item.value === value)
  return (
    <Page title={current?.label ?? ""}>
      <TabBar value={value} onValueChange={setValue}>
        <TabBarList>
          {items.map((item) => (
            <TabBarItem
              key={item.value}
              value={item.value}
              icon={item.icon}
              badge={badges ? item.badge : undefined}
              disabled={item.value === disabled}
            >
              {item.label}
            </TabBarItem>
          ))}
        </TabBarList>
      </TabBar>
    </Page>
  )
}

export function TabBarDisabledItemExample() {
  return (
    <TabBarExample disabled="search" />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TabBarDisabledItemExample: View {
  var body: some View {
    TabBarExample(disabled: "search")
  }
}

struct TabBarEntry {
  let value: String
  let label: String
  let icon: Image
  let activeIcon: Image
  let badge: TabBarBadge?
}

let tabBarEntries = [
  TabBarEntry(value: "home", label: "Home", icon: Ph.house.regular, activeIcon: Ph.house.fill, badge: nil),
  TabBarEntry(value: "search", label: "Search", icon: Ph.magnifyingGlass.regular, activeIcon: Ph.magnifyingGlass.fill, badge: nil),
  TabBarEntry(value: "messages", label: "Messages", icon: Ph.chatCircle.regular, activeIcon: Ph.chatCircle.fill, badge: 3),
  TabBarEntry(value: "profile", label: "Profile", icon: Ph.user.regular, activeIcon: Ph.user.fill, badge: .dot),
]

struct TabBarPage<Bar: View>: View {
  let title: String
  @ViewBuilder let bar: () -> Bar

  var body: some View {
    VStack(spacing: 0) {
      VStack(alignment: .leading, spacing: 8) {
        Text(verbatim: title).textVariant(.h3)
        Text("Tap a tab to switch screens.").textVariant(.muted)
      }
      .padding(16)
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
      bar()
    }
    .background(EsuissColor.background.ignoresSafeArea())
  }
}

struct TabBarExample: View {
  var badges = true
  var disabled: String?

  @State private var value = "home"

  var body: some View {
    TabBarPage(title: tabBarEntries.first { $0.value == value }?.label ?? "") {
      TabBar(selection: $value) {
        ForEach(tabBarEntries, id: \.value) { item in
          TabBarItem(
            verbatim: item.label,
            value: item.value,
            icon: item.icon,
            activeIcon: item.activeIcon,
            badge: badges ? item.badge : nil
          )
          .disabled(item.value == disabled)
        }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.fill.House
import com.adamglin.phosphoricons.fill.MagnifyingGlass
import com.adamglin.phosphoricons.fill.User
import com.adamglin.phosphoricons.regular.ChatCircle
import com.adamglin.phosphoricons.regular.House
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.User
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun TabBarDisabledItemExample() {
    TabBarExample(disabled = "search")
}

class TabBarEntry(val value: String, val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val badge: String? = null)

val tabBarEntries = listOf(
    TabBarEntry("home", "Home", PhosphorIcons.Regular.House, PhosphorIcons.Fill.House),
    TabBarEntry("search", "Search", PhosphorIcons.Regular.MagnifyingGlass, PhosphorIcons.Fill.MagnifyingGlass),
    TabBarEntry("messages", "Messages", PhosphorIcons.Regular.ChatCircle, PhosphorIcons.Fill.ChatCircle, "3"),
    TabBarEntry("profile", "Profile", PhosphorIcons.Regular.User, PhosphorIcons.Fill.User, ""),
)

@Composable
fun TabBarPage(title: String, bar: @Composable () -> Unit) {
    Screen(edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        Column(Modifier.fillMaxSize()) {
            Column(
                Modifier.weight(1f).fillMaxWidth().padding(16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                Text(title, variant = TextVariant.H3)
                Text("Tap a tab to switch screens.", variant = TextVariant.Muted)
            }
            bar()
        }
    }
}

@Composable
fun TabBarExample(badges: Boolean = true, disabled: String? = null) {
    var value by rememberSaveable { mutableStateOf("home") }
    val current = tabBarEntries.firstOrNull { it.value == value }
    TabBarPage(current?.label ?: "") {
        TabBar(value = value, onValueChange = { value = it }) {
            tabBarEntries.forEach { entry ->
                TabBarItem(
                    value = entry.value,
                    label = entry.label,
                    icon = entry.icon,
                    selectedIcon = entry.selectedIcon,
                    badge = if (badges) entry.badge else null,
                    enabled = entry.value != disabled,
                )
            }
        }
    }
}
```

## API Reference

### TabBar

Root that holds the active tab, the side inset and the safe area padding.

Also accepts the props of `React.ComponentProps<typeof TabsPrimitive.Root>`.

### TabBarList

Capsule that holds the tabs.

Also accepts the props of `React.ComponentProps<typeof TabsPrimitive.List>`.

### TabBarItem

One tab with an icon, a label and an optional badge.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon` | `PhosphorIcon` | - | Phosphor icon shown above the label. Uses the fill weight when active. |
| `badge` | `TabBarBadgeValue` | - | Number, text or `true` for a dot, shown on the icon's corner. |

Also accepts the props of `React.ComponentProps<typeof TabsPrimitive.Tab>`.

## Accessibility

- The list has the `tablist` role and each item has the `tab` role, with selected and disabled states.
- A badge with a count or text is exposed as the tab's accessibility value, so VoiceOver and TalkBack read it after the label.
- A dot badge is visual only and is not announced.

## Notes

- The capsule uses `bg-card` with a `border-foreground/5` border (`border-foreground/10` in dark mode) and a large shadow. The active tab uses `bg-secondary` and `text-primary`, and badges use `bg-destructive`; colors follow the system appearance.
- The bottom padding is the remaining safe area inset minus 8 points, and never less than 12 points.

