{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tab-bar",
  "type": "registry:ui",
  "title": "Tab Bar",
  "description": "Phone bottom navigation whose icons fill when active, with rolling badge counts.",
  "categories": [
    "app-nav"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/tab-bar.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { cloneElement, createContext, isValidElement, useContext, useId } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Context\n * -----------------------------------------------------------------------------------------------*/\n\ntype TabBarContext = {\n  value: string;\n  select: (value: string) => void;\n  onReselect?: (value: string) => void;\n  indicator: boolean;\n  layoutId: string;\n  reduce: boolean;\n};\n\nconst Ctx = createContext<TabBarContext | null>(null);\nconst useTabBar = () => {\n  const ctx = useContext(Ctx);\n  if (!ctx) throw new Error(\"TabBarItem must be used inside TabBar\");\n  return ctx;\n};\n\n/* -------------------------------------------------------------------------------------------------\n * TabBar\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TabBarProps = Omit<React.ComponentProps<\"nav\">, \"defaultValue\"> & {\n  /** The active item's value. Pair with onValueChange to control it. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Called when the active item is pressed again. Scroll its view to the top here. */\n  onReselect?: (value: string) => void;\n  /** fixed pins to the viewport; absolute pins to the nearest positioned parent (a phone frame, a demo). */\n  position?: \"fixed\" | \"absolute\" | \"static\";\n  /** A soft pill behind the active icon that slides between items. */\n  indicator?: boolean;\n};\n\n/**\n * A phone's bottom navigation: 3–5 destinations, icon over label, sat above the\n * home indicator. The active icon fills, and one pill slides to it.\n */\nexport function TabBar({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onReselect,\n  position = \"fixed\",\n  indicator = true,\n  className,\n  style,\n  children,\n  \"aria-label\": ariaLabel = \"Main\",\n  ...rest\n}: TabBarProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = !!useReducedMotion();\n  const layoutId = useId();\n\n  return (\n    <Ctx.Provider value={{ value, select: setValue, onReselect, indicator, layoutId, reduce }}>\n      <nav\n        aria-label={ariaLabel}\n        data-position={position}\n        style={{ \"--tab-cut\": \"var(--frame)\", ...style } as React.CSSProperties}\n        className={cn(\n          \"select-none border-t border-line bg-frame/92 backdrop-blur-md backdrop-saturate-150\",\n          // Sits above the home indicator. --tab-bar-inset is the floor on devices without one\n          // (and lets a mock phone frame fake the inset).\n          \"pb-[max(var(--tab-bar-inset,6px),env(safe-area-inset-bottom))]\",\n          position === \"fixed\" && \"fixed inset-x-0 bottom-0 z-(--z-sticky)\",\n          position === \"absolute\" && \"absolute inset-x-0 bottom-0 z-(--z-sticky)\",\n          className,\n        )}\n        {...rest}\n      >\n        <LayoutGroup id={layoutId}>\n          <ul className=\"mx-auto flex h-[52px] max-w-lg items-stretch px-1\">{children}</ul>\n        </LayoutGroup>\n      </nav>\n    </Ctx.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * TabBarItem\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TabBarItemProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"children\"> & {\n  value: string;\n  label: string;\n  /**\n   * The icon. Draw it with `data-fill` on the shapes that fill when active and `data-cut` on\n   * the details that turn into cut-outs; TabBarIcons are drawn this way. A function receives\n   * the active state if you'd rather swap two glyphs yourself.\n   */\n  icon: React.ReactNode | ((active: boolean) => React.ReactNode);\n  /** A number shows a rolling count (99+ past 99); true shows a dot. 0 or false hides it. */\n  badge?: number | boolean;\n  /** What the badge means, read after the label. Defaults to \"N new\" or \"New activity\". */\n  badgeLabel?: string;\n  /** Renders a link instead of a button. */\n  href?: string;\n  /** Your own element (a router Link) to render the item as. It receives every prop and the content. */\n  render?: React.ReactElement<Record<string, unknown>>;\n};\n\nexport function TabBarItem({\n  value,\n  label,\n  icon,\n  badge,\n  badgeLabel,\n  href,\n  render,\n  disabled,\n  className,\n  onClick,\n  ...rest\n}: TabBarItemProps) {\n  const { value: current, select, onReselect, indicator, reduce } = useTabBar();\n  const active = current === value;\n  const count = typeof badge === \"number\" ? badge : 0;\n  const showBadge = badge === true || count > 0;\n  const announce = badgeLabel ?? (typeof badge === \"number\" ? `${count} new` : \"New activity\");\n\n  const handle = (e: React.MouseEvent<HTMLElement>) => {\n    onClick?.(e as React.MouseEvent<HTMLButtonElement>);\n    if (e.defaultPrevented || disabled) return;\n    if (active) onReselect?.(value);\n    else select(value);\n  };\n\n  const props = {\n    ...rest,\n    \"data-active\": active ? \"\" : undefined,\n    \"data-disabled\": disabled ? \"\" : undefined,\n    \"aria-current\": active ? (\"page\" as const) : undefined,\n    \"aria-disabled\": disabled || undefined,\n    onClick: handle,\n    className: cn(\n      \"group/tab relative flex h-full w-full min-w-0 flex-col items-center justify-center gap-[3px] rounded-xl pt-1\",\n      \"touch-manipulation [-webkit-tap-highlight-color:transparent] outline-none\",\n      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n      \"text-fg-3 transition-colors duration-150 hover:text-fg-2 data-active:text-fg\",\n      \"data-disabled:pointer-events-none data-disabled:opacity-40\",\n      className,\n    ),\n  };\n\n  const content = (\n    <>\n      <span className=\"relative grid h-7 w-14 place-items-center\">\n        {indicator && active && (\n          <motion.span\n            layoutId=\"indicator\"\n            aria-hidden\n            className=\"absolute inset-0 rounded-full bg-fg/[0.08]\"\n            transition={reduce ? { duration: 0 } : spring.snappy}\n          />\n        )}\n        <span\n          className={cn(\n            \"relative grid size-6 place-items-center [&_svg]:size-6\",\n            // The press: the icon gives a little under the thumb and settles back.\n            \"transition-transform duration-200 ease-out-expo group-active/tab:scale-[0.86] group-active/tab:duration-100 motion-reduce:transform-none\",\n            // The morph: shapes marked data-fill fill in, details marked data-cut become cut-outs, strokes firm up.\n            \"[&_path,&_circle,&_rect]:transition-[fill-opacity,stroke,stroke-width,fill] [&_path,&_circle,&_rect]:duration-200 [&_path,&_circle,&_rect]:ease-out-expo\",\n            \"[&_[data-fill]]:fill-current [&_[data-fill]]:[fill-opacity:0] group-data-active/tab:[&_[data-fill]]:[fill-opacity:1]\",\n            \"[&_[data-cut]]:[fill:transparent] group-data-active/tab:[&_[data-cut]]:[fill:var(--tab-cut)] group-data-active/tab:[&_[data-cut]]:[stroke:var(--tab-cut)]\",\n            \"group-data-active/tab:[&_[data-bold]]:[stroke-width:2.1]\",\n          )}\n        >\n          {typeof icon === \"function\" ? icon(active) : icon}\n        </span>\n        <Badge show={showBadge} count={count} dot={badge === true} reduce={reduce} />\n      </span>\n      <span className=\"max-w-full truncate px-0.5 text-[10.5px] font-medium leading-[14px] tracking-[0.01em]\">{label}</span>\n      {showBadge && <span className=\"sr-only\">, {announce}</span>}\n    </>\n  );\n\n  let el: React.ReactNode;\n  if (render && isValidElement(render)) el = cloneElement(render, { ...props, href: href ?? (render.props.href as string | undefined) }, content);\n  else if (href) el = <a href={disabled ? undefined : href} {...(props as React.ComponentProps<\"a\">)}>{content}</a>;\n  else el = <button type=\"button\" disabled={disabled} {...props}>{content}</button>;\n\n  return <li className=\"flex min-w-0 flex-1 justify-center\">{el}</li>;\n}\n\n// The badge pops in from the icon's corner the moment a count arrives, rolls its\n// digits as the count changes, and shrinks back into the corner at zero. Its ring\n// is the bar's own color so it reads as cut out of the icon.\nfunction Badge({ show, count, dot, reduce }: { show: boolean; count: number; dot: boolean; reduce: boolean }) {\n  const capped = Math.min(count, 99);\n  return (\n    <AnimatePresence initial={false}>\n      {show && (\n        <motion.span\n          key=\"badge\"\n          aria-hidden\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: 0.14 } }}\n          transition={reduce ? { duration: 0.15 } : spring.pop}\n          className={cn(\n            \"absolute left-[calc(50%+5px)] origin-bottom-left rounded-full bg-fg text-frame ring-2 ring-(--tab-cut)\",\n            dot ? \"top-0.5 size-2\" : \"-top-0.5 flex h-4 min-w-4 items-center justify-center px-1 text-[10px] font-semibold leading-none tabular\",\n          )}\n        >\n          {!dot && <NumberFlow value={capped} suffix={count > 99 ? \"+\" : undefined} className=\"[--number-flow-mask-height:0.15em]\" />}\n        </motion.span>\n      )}\n    </AnimatePresence>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Icons: 24px grid, 1.6 stroke, marked up for the fill morph\n * -----------------------------------------------------------------------------------------------*/\n\nconst svg = (children: React.ReactNode, name: string) => {\n  const Icon = (props: React.SVGProps<SVGSVGElement>) => (\n    <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden focusable={false} {...props}>\n      {children}\n    </svg>\n  );\n  Icon.displayName = name;\n  return Icon;\n};\n\n/** Tab icons drawn for the outline-to-filled morph. Use them as-is, or copy the data-fill / data-cut markup. */\nexport const TabBarIcons = {\n  Home: svg(\n    <>\n      <path data-fill d=\"M3.75 10.4 12 3.75l8.25 6.65v8.85a1 1 0 0 1-1 1H4.75a1 1 0 0 1-1-1z\" />\n      <path data-cut d=\"M9.75 20.25v-4.5a1 1 0 0 1 1-1h2.5a1 1 0 0 1 1 1v4.5z\" />\n    </>,\n    \"TabHome\",\n  ),\n  Search: svg(\n    <>\n      <circle data-bold cx=\"10.75\" cy=\"10.75\" r=\"6.25\" />\n      <path data-bold d=\"m15.5 15.5 4.75 4.75\" />\n    </>,\n    \"TabSearch\",\n  ),\n  Inbox: svg(\n    <>\n      <path data-fill d=\"M3.75 13.25 6.1 5.6a1.5 1.5 0 0 1 1.43-1.1h8.94a1.5 1.5 0 0 1 1.43 1.1l2.35 7.65v5.25a1.5 1.5 0 0 1-1.5 1.5H5.25a1.5 1.5 0 0 1-1.5-1.5z\" />\n      <path data-cut d=\"M4 13.25h4.25l1 2.25h5.5l1-2.25H20\" />\n    </>,\n    \"TabInbox\",\n  ),\n  Bell: svg(\n    <>\n      <path data-fill d=\"M6 16.75V11a6 6 0 0 1 12 0v5.75l1.5 1.5h-15z\" />\n      <path data-bold d=\"M10 20.75a2.25 2.25 0 0 0 4 0\" />\n    </>,\n    \"TabBell\",\n  ),\n  User: svg(\n    <>\n      <circle data-fill cx=\"12\" cy=\"8.25\" r=\"3.75\" />\n      <path data-fill d=\"M4.75 20.25c.55-3.65 3.55-5.75 7.25-5.75s6.7 2.1 7.25 5.75z\" />\n    </>,\n    \"TabUser\",\n  ),\n  Calendar: svg(\n    <>\n      <rect data-fill x=\"3.75\" y=\"5\" width=\"16.5\" height=\"15.25\" rx=\"2.25\" />\n      <path data-cut d=\"M4 9.75h16\" />\n      <path d=\"M8 3v3.5M16 3v3.5\" />\n    </>,\n    \"TabCalendar\",\n  ),\n  Chart: svg(\n    <>\n      <rect data-fill x=\"4\" y=\"12\" width=\"4\" height=\"8\" rx=\"1\" />\n      <rect data-fill x=\"10\" y=\"7.5\" width=\"4\" height=\"12.5\" rx=\"1\" />\n      <rect data-fill x=\"16\" y=\"4\" width=\"4\" height=\"16\" rx=\"1\" />\n    </>,\n    \"TabChart\",\n  ),\n};\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/tab-bar"
}
