{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sidebar",
  "type": "registry:ui",
  "title": "Sidebar",
  "description": "App sidebar whose active pill springs between rows and whose counts roll.",
  "categories": [
    "app-nav"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/sidebar.tsx",
      "content": "\"use client\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronRight, ChevronsUpDown } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Root\n * -----------------------------------------------------------------------------------------------*/\n\ntype SidebarContextValue = {\n  value: string | null;\n  select: (value: string) => void;\n  /** Scopes the active pill's layoutId to this sidebar, so two sidebars never trade pills. */\n  pill: string;\n};\n\nconst SidebarContext = createContext<SidebarContextValue | null>(null);\n\nfunction useSidebar(part: string) {\n  const ctx = useContext(SidebarContext);\n  if (!ctx) throw new Error(`<${part}> must be inside <Sidebar>`);\n  return ctx;\n}\n\nexport type SidebarProps = Omit<React.ComponentProps<\"aside\">, \"defaultValue\" | \"onChange\"> & {\n  /** The value of the item for the current page. Pass it from your router to keep the sidebar in sync. */\n  value?: string | null;\n  defaultValue?: string | null;\n  /** Called when an item is pressed, before any navigation its link performs. */\n  onValueChange?: (value: string | null) => void;\n};\n\nexport function Sidebar({ value, defaultValue = null, onValueChange, className, children, ...rest }: SidebarProps) {\n  const [current, setCurrent] = useControllableState<string | null>({ value, defaultValue, onChange: onValueChange });\n  const pill = useId();\n  const ctx = useMemo(() => ({ value: current, select: (v: string) => setCurrent(v), pill }), [current, setCurrent, pill]);\n\n  return (\n    <SidebarContext.Provider value={ctx}>\n      <aside className={cn(\"flex h-full min-h-0 w-60 shrink-0 flex-col border-r border-line bg-frame text-fg\", className)} {...rest}>\n        {children}\n      </aside>\n    </SidebarContext.Provider>\n  );\n}\n\nexport type SidebarHeaderProps = React.ComponentProps<\"div\">;\n\n/** The top of the sidebar: the workspace, a search or a compose button. Never scrolls. */\nexport function SidebarHeader({ className, ...rest }: SidebarHeaderProps) {\n  return <div className={cn(\"flex shrink-0 items-center gap-1 px-3 pb-1.5 pt-3\", className)} {...rest} />;\n}\n\nexport type SidebarFooterProps = React.ComponentProps<\"div\">;\n\n/** The bottom of the sidebar: the signed-in person, a help link. Never scrolls. */\nexport function SidebarFooter({ className, ...rest }: SidebarFooterProps) {\n  return <div className={cn(\"shrink-0 border-t border-line p-2\", className)} {...rest} />;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Content: the scrolling list, the hover wash and the arrow keys\n * -----------------------------------------------------------------------------------------------*/\n\ntype WashContextValue = { show: (el: HTMLElement) => void; hide: () => void };\nconst WashContext = createContext<WashContextValue | null>(null);\n\n// One wash for the whole list, moved to whichever row the pointer is over. It glides\n// between rows on the follow spring, and appears in place (a fade, no travel) when the\n// pointer first arrives so it never sweeps in from the last row it visited. Motion\n// values keep the position off React's render path.\nfunction useWash(reduce: boolean) {\n  const listRef = useRef<HTMLDivElement>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(0);\n  const [on, setOn] = useState(false);\n  const shown = useRef(false);\n  const hiddenAt = useRef(0);\n\n  const show = useCallback(\n    (el: HTMLElement) => {\n      const list = listRef.current;\n      if (!list) return;\n      const top = el.getBoundingClientRect().top - list.getBoundingClientRect().top;\n      // Still fading out from a moment ago: glide from there instead of reappearing.\n      const glide = !reduce && (shown.current || performance.now() - hiddenAt.current < 150);\n      if (glide) {\n        animate(y, top, spring.follow);\n        animate(height, el.offsetHeight, spring.follow);\n      } else {\n        y.jump(top);\n        height.jump(el.offsetHeight);\n      }\n      shown.current = true;\n      setOn(true);\n    },\n    [reduce, y, height],\n  );\n\n  const hide = useCallback(() => {\n    if (!shown.current) return;\n    shown.current = false;\n    hiddenAt.current = performance.now();\n    setOn(false);\n  }, []);\n\n  return { listRef, y, height, on, show, hide };\n}\n\nexport type SidebarContentProps = React.ComponentProps<\"nav\">;\n\n/**\n * The scrolling middle of the sidebar, rendered as a labeled nav landmark. Fades its\n * edges only when there is more to scroll to, and lets ↑ ↓ Home End move between rows.\n */\nexport function SidebarContent({ className, children, onKeyDown, \"aria-label\": label = \"Main\", ...rest }: SidebarContentProps) {\n  const reduce = !!useReducedMotion();\n  const { listRef, y, height, on, show, hide } = useWash(reduce);\n  const wash = useMemo(() => ({ show, hide }), [show, hide]);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [edges, setEdges] = useState({ top: false, bottom: false });\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    const list = listRef.current;\n    if (!el || !list) return;\n    const measure = () => {\n      const top = el.scrollTop > 1;\n      const bottom = el.scrollTop + el.clientHeight < el.scrollHeight - 1;\n      setEdges((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom }));\n    };\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    ro.observe(list);\n    el.addEventListener(\"scroll\", measure, { passive: true });\n    return () => {\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", measure);\n    };\n  }, [listRef]);\n\n  return (\n    <WashContext.Provider value={wash}>\n      <nav\n        aria-label={label}\n        className={cn(\"relative flex min-h-0 flex-1 flex-col\", className)}\n        onKeyDown={(e) => {\n          onKeyDown?.(e);\n          if (e.defaultPrevented || ![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(e.key)) return;\n          const rows = [...(listRef.current?.querySelectorAll<HTMLElement>(\"[data-sidebar-row]\") ?? [])].filter(\n            (el) => !el.closest(\"[hidden]\") && !el.matches(\":disabled,[aria-disabled=true]\"),\n          );\n          const at = rows.indexOf(e.target as HTMLElement);\n          if (at < 0) return;\n          e.preventDefault();\n          const next = e.key === \"Home\" ? 0 : e.key === \"End\" ? rows.length - 1 : Math.min(rows.length - 1, Math.max(0, at + (e.key === \"ArrowDown\" ? 1 : -1)));\n          rows[next]?.focus();\n        }}\n        {...rest}\n      >\n        <motion.div\n          ref={scrollRef}\n          layoutScroll\n          data-edge-top={edges.top || undefined}\n          data-edge-bottom={edges.bottom || undefined}\n          className={cn(\n            \"min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-3 [scrollbar-width:none]\",\n            // The edge fades only where there is more to see, so a short list reads as complete.\n            \"[--fade-top:0px] [--fade-bottom:0px] data-edge-top:[--fade-top:16px] data-edge-bottom:[--fade-bottom:16px]\",\n            \"[mask-image:linear-gradient(to_bottom,transparent,var(--fg)_var(--fade-top),var(--fg)_calc(100%-var(--fade-bottom)),transparent)]\",\n          )}\n        >\n          <div ref={listRef} className=\"relative flex flex-col\" onPointerLeave={hide}>\n            <motion.span\n              aria-hidden\n              className=\"pointer-events-none absolute inset-x-0 top-0 rounded-md bg-hover\"\n              style={{ y, height }}\n              initial={false}\n              animate={{ opacity: on ? 1 : 0 }}\n              transition={{ duration: on ? 0.12 : 0.15, ease: \"easeOut\" }}\n            />\n            {children}\n          </div>\n        </motion.div>\n      </nav>\n    </WashContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Group\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SidebarGroupProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** The section's name. Omit it for the first, unlabeled block of items. */\n  label?: React.ReactNode;\n  /** Lets the label fold the section away. */\n  collapsible?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** A small control at the end of the label row, such as \"Add team\". Keep it icon-sized. */\n  action?: React.ReactNode;\n};\n\nconst labelText = \"font-mono text-[10.5px] uppercase leading-none tracking-[0.08em] text-fg-3\";\n\nexport function SidebarGroup({ label, collapsible = false, open, defaultOpen = true, onOpenChange, action, className, children, ...rest }: SidebarGroupProps) {\n  const labelId = useId();\n  const wash = useContext(WashContext);\n  const list = (\n    <div role=\"group\" aria-labelledby={label ? labelId : undefined} className=\"flex flex-col gap-px\">\n      {children}\n    </div>\n  );\n\n  if (!collapsible || !label) {\n    return (\n      <div className={cn(\"flex flex-col pt-3 first:pt-0.5\", className)} {...rest}>\n        {label && (\n          <div className=\"flex h-7 items-center justify-between gap-2 pl-2 pr-1\">\n            <span id={labelId} className={labelText}>\n              {label}\n            </span>\n            {action}\n          </div>\n        )}\n        {list}\n      </div>\n    );\n  }\n\n  return (\n    <Collapsible.Root\n      open={open}\n      defaultOpen={defaultOpen}\n      onOpenChange={(next) => onOpenChange?.(next)}\n      className={cn(\"group/sg flex flex-col pt-3 first:pt-0.5\", className)}\n      {...rest}\n    >\n      <div className=\"relative flex h-7 items-center\">\n        <Collapsible.Trigger\n          data-sidebar-row=\"\"\n          // The wash belongs to rows; leaving it behind would strand it over a row that is folding away.\n          onPointerEnter={() => wash?.hide()}\n          className={cn(\n            \"group/trigger relative flex h-full min-w-0 flex-1 items-center gap-1 rounded-md pl-2 pr-8 text-left outline-none\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n            \"transition-[color,scale] duration-120 active:scale-[0.98] pointer-coarse:h-9\",\n          )}\n        >\n          <span id={labelId} className={cn(labelText, \"truncate transition-colors duration-150 group-hover/trigger:text-fg-2\")}>\n            {label}\n          </span>\n          <ChevronRight\n            size={12}\n            className={cn(\n              \"shrink-0 text-fg-3 transition-[rotate,opacity] duration-200 ease-out-expo group-data-panel-open/trigger:rotate-90\",\n              // The chevron is a hint on pointers, always there on touch and whenever the section is folded.\n              \"opacity-0 group-hover/trigger:opacity-100 group-focus-visible/trigger:opacity-100 group-data-closed/sg:opacity-100 pointer-coarse:opacity-100\",\n            )}\n          />\n          {/* A folded section that hides the current page keeps a mark of it on its label. */}\n          <span\n            aria-hidden\n            className=\"ml-0.5 size-1 shrink-0 scale-50 rounded-full bg-fg opacity-0 transition-[opacity,scale] duration-200 ease-out-expo group-data-closed/sg:group-has-[[aria-current=page]]/sg:scale-100 group-data-closed/sg:group-has-[[aria-current=page]]/sg:opacity-100\"\n          />\n        </Collapsible.Trigger>\n        {action && <div className=\"absolute right-1 top-1/2 -translate-y-1/2\">{action}</div>}\n      </div>\n      <Collapsible.Panel\n        hiddenUntilFound\n        className={cn(\n          \"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-220 ease-in-out-quart\",\n          \"data-starting-style:h-0 data-ending-style:h-0 data-ending-style:duration-180 [&[hidden]:not([hidden=until-found])]:hidden\",\n        )}\n      >\n        <div className=\"transition-opacity duration-200 ease-out group-data-closed/sg:opacity-0 group-data-closed/sg:duration-120\">{list}</div>\n      </Collapsible.Panel>\n    </Collapsible.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Item\n * -----------------------------------------------------------------------------------------------*/\n\nexport type SidebarItemProps = Omit<useRender.ComponentProps<\"a\">, \"children\"> & {\n  /** Identifies the item. It is active when it equals the sidebar's value. */\n  value: string;\n  /** A 16px icon. Use a letter tile or an avatar for teams and people. */\n  icon?: React.ReactNode;\n  /** Rolls when it changes and folds away at 0. */\n  count?: number;\n  /** Anything else at the end of the row: a shortcut, a status dot, a \"New\" chip. */\n  trailing?: React.ReactNode;\n  disabled?: boolean;\n  children: React.ReactNode;\n};\n\n/**\n * A row in the sidebar. A link when it has an href (or a `render` element such as your\n * router's Link), otherwise a button. The active row carries aria-current=\"page\".\n */\nexport function SidebarItem({ value, icon, count, trailing, disabled = false, render, className, children, onClick, onPointerEnter, href, ...rest }: SidebarItemProps) {\n  const { value: current, select, pill } = useSidebar(\"SidebarItem\");\n  const wash = useContext(WashContext);\n  const reduce = useReducedMotion();\n  const active = current === value;\n  const isLink = !!href || !!render;\n\n  const element = useRender({\n    defaultTagName: isLink ? \"a\" : \"button\",\n    render: render as useRender.ComponentProps<\"a\">[\"render\"],\n    props: mergeProps<\"a\">(\n      ({\n        href: disabled ? undefined : href,\n        \"aria-current\": active ? \"page\" : undefined,\n        \"aria-disabled\": disabled || undefined,\n        ...(isLink ? {} : ({ type: \"button\", disabled } as object)),\n        \"data-sidebar-row\": \"\",\n        \"data-active\": active || undefined,\n        \"data-disabled\": disabled || undefined,\n        onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {\n          if (disabled) return e.preventDefault();\n          onClick?.(e);\n          if (!e.defaultPrevented || isLink) select(value);\n        },\n        onPointerEnter: (e: React.PointerEvent<HTMLAnchorElement>) => {\n          onPointerEnter?.(e);\n          if (e.pointerType === \"touch\") return;\n          if (disabled) wash?.hide();\n          else wash?.show(e.currentTarget);\n        },\n        className: cn(\n          \"group/item relative flex h-8 w-full min-w-0 select-none items-center gap-2.5 rounded-md px-2 text-left text-[13px] leading-none text-fg-2 no-underline outline-none\",\n          \"transition-[color,scale,background-color] duration-150 active:scale-[0.985] active:duration-75 pointer-coarse:h-11 pointer-coarse:active:bg-hover\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          \"hover:text-fg data-active:text-fg\",\n          // Outside a SidebarContent there is no shared wash, so the row lights itself.\n          !wash && \"hover:bg-hover\",\n          \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n          isLink && !disabled && \"cursor-pointer\",\n          className,\n        ),\n        children: (\n          <>\n            {active && (\n              <motion.span\n                layoutId={pill}\n                aria-hidden\n                className=\"absolute inset-0 rounded-md border border-line-2 bg-raised shadow-[var(--shadow)]\"\n                transition={reduce ? { duration: 0 } : spring.snappy}\n              />\n            )}\n            {icon && (\n              <span aria-hidden className=\"relative grid size-4 shrink-0 place-items-center text-fg-3 transition-colors duration-150 group-hover/item:text-fg-2 group-data-active/item:text-fg [&_svg]:size-4\">\n                {icon}\n              </span>\n            )}\n            <span className=\"relative min-w-0 flex-1 truncate py-0.5 leading-[18px]\">{children}</span>\n            {trailing && <span className=\"relative flex shrink-0 items-center\">{trailing}</span>}\n            <SidebarCount value={count} />\n          </>\n        ),\n      } as useRender.ElementProps<\"a\">),\n      rest,\n    ),\n  });\n\n  return element;\n}\n\n// Counts roll digit by digit and fold away when they reach zero, so \"3 unread\" going\n// to \"read\" is one continuous motion instead of a number that vanishes.\nfunction SidebarCount({ value }: { value?: number }) {\n  const reduce = useReducedMotion();\n  const shown = typeof value === \"number\" && value > 0;\n  return (\n    <AnimatePresence initial={false}>\n      {shown && (\n        <motion.span\n          key=\"count\"\n          className=\"relative -mr-0.5 flex h-4 min-w-4 shrink-0 items-center justify-end text-[11.5px] tabular text-fg-3 transition-colors duration-150 group-data-active/item:text-fg-2\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n          animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\", transition: { duration: 0.14 } }}\n          transition={reduce ? { duration: 0.15 } : spring.pop}\n        >\n          <NumberFlow value={value} format={{ notation: value >= 1000 ? \"compact\" : \"standard\" }} />\n        </motion.span>\n      )}\n    </AnimatePresence>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * User\n * -----------------------------------------------------------------------------------------------*/\n\ntype Presence = \"online\" | \"away\" | \"busy\" | \"offline\";\nconst presenceLabel: Record<Presence, string> = { online: \"Online\", away: \"Away\", busy: \"Do not disturb\", offline: \"Offline\" };\nconst presenceClass: Record<Presence, string> = { online: \"bg-success\", away: \"bg-warning\", busy: \"bg-danger\", offline: \"bg-fg-4\" };\n\nexport type SidebarUserProps = Omit<useRender.ComponentProps<\"button\">, \"children\"> & {\n  name: string;\n  /** A second line: an email, a role, a plan. Truncates before the name does. */\n  description?: string;\n  /** Image URL. Falls back to initials if missing or broken. */\n  avatar?: string;\n  status?: Presence;\n};\n\n/**\n * The signed-in person, at the foot of the sidebar. A button, so it can open your\n * account menu: pass it to a menu trigger's `render` prop.\n */\nexport function SidebarUser({ name, description, avatar, status, render, className, ...rest }: SidebarUserProps) {\n  const [broken, setBroken] = useState(false);\n  const initials = name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((p) => p[0]!.toUpperCase())\n    .join(\"\");\n\n  return useRender({\n    defaultTagName: \"button\",\n    render,\n    props: mergeProps<\"button\">(\n      {\n        type: \"button\",\n        className: cn(\n          \"group/user flex h-11 w-full min-w-0 select-none items-center gap-2.5 rounded-lg px-1.5 text-left outline-none\",\n          \"transition-[background-color,scale] duration-150 hover:bg-hover active:scale-[0.985] active:duration-75 data-popup-open:bg-hover\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          className,\n        ),\n        children: (\n          <>\n            <span className=\"relative size-7 shrink-0\">\n              <span className=\"grid size-full place-items-center overflow-hidden rounded-full border border-line-2 bg-raised text-[11px] font-medium text-fg-2\">\n                {avatar && !broken ? (\n                  // eslint-disable-next-line @next/next/no-img-element\n                  <img src={avatar} alt=\"\" width={28} height={28} className=\"size-full object-cover\" onError={() => setBroken(true)} />\n                ) : (\n                  <span aria-hidden>{initials}</span>\n                )}\n              </span>\n              {status && <span aria-hidden className={cn(\"absolute -bottom-px -right-px size-2.5 rounded-full border-2 border-frame\", presenceClass[status])} />}\n            </span>\n            <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n              <span className=\"truncate text-[13px] font-medium leading-4 tracking-[-0.005em] text-fg\">{name}</span>\n              {description && <span className=\"truncate text-[11.5px] leading-[14px] text-fg-3\">{description}</span>}\n            </span>\n            {status && <span className=\"sr-only\">, {presenceLabel[status]}</span>}\n            <ChevronsUpDown size={14} className=\"shrink-0 text-fg-3 transition-colors duration-150 group-hover/user:text-fg-2\" />\n          </>\n        ),\n      },\n      rest,\n    ),\n  });\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/sidebar"
}
