{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-area",
  "type": "registry:ui",
  "title": "Scroll area",
  "description": "A region that scrolls inside the page: native scrolling with litt's 6px thumb, shown while it scrolls or a mouse is over it, and a hairline on any edge with more beyond it. While it scrolls it is a labelled Tab stop with an inset focus ring. No gradient fades.",
  "dependencies": [
    "@radix-ui/react-scroll-area"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/scroll-area.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/scroll-area 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * ScrollArea: a region that scrolls inside the page, such as a log, a long list in a fixed-height card or a side\n * panel. Never the page itself (components.md, Scroll area; decision 21). Built on Radix ScrollArea: the scrolling\n * stays native (wheel, touch, keyboard, momentum), and only the scrollbar is litt's.\n *\n * - Thumb: 6px at the control radius, fg 24%, fg 40% while its strip is hovered or dragged (instant tier, color\n *   only). It sits on a transparent track with 2px of inset, so the hit area is 10px, and it overlays the content.\n * - When it shows: while the area scrolls, or while a mouse is over it, then it fades out on the instant tier\n *   after a short pause. On coarse pointers there is no hover, so it shows only while scrolling. Nothing shows\n *   or fades before a person has touched, scrolled, hovered or focused the area: a log that starts at its end\n *   does not flash a thumb on load.\n * - Edges: a 1px --litt-line hairline appears on any edge with more content beyond it, and goes when that edge is\n *   reached (instant tier, color only). No gradient fades, because material is static (R2).\n * - Focus: when it scrolls, the viewport is a labelled region and a Tab stop, so the arrow keys, Page Up and Down,\n *   Home and End scroll it. Keyboard focus draws an inset 2px focus ring, inside its edges.\n * - Axes: vertical by default, horizontal or both on request. Vertical content is a block, so text wraps; sideways\n *   content keeps its own width and scrolls.\n * - Reduced motion: the duration token is zero, so the thumb and the hairlines change at once.\n * - Flat: no edge hairlines; only the thumb shows there is more.\n */\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type Ref } from \"react\";\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\";\nimport { useDevice } from \"../../lib/litt/motion\";\n\n/** How long the thumb stays after the last scroll, or after the pointer leaves (an interaction delay, not animation). */\nconst HIDE_DELAY = 600; // ms\n\ntype Edges = { top: boolean; bottom: boolean; left: boolean; right: boolean; scrollsY: boolean; scrollsX: boolean };\nconst NONE: Edges = { top: false, bottom: false, left: false, right: false, scrollsY: false, scrollsX: false };\nconst same = (a: Edges, b: Edges) => (Object.keys(a) as (keyof Edges)[]).every((k) => a[k] === b[k]);\n\n/* Classes once the area is armed (see the header): before that, a change snaps. */\nconst FADE = \"transition-opacity duration-(--litt-dur-instant) ease-litt \";\nconst TINT = \"transition-colors duration-(--litt-dur-instant) ease-litt \";\n\nconst THUMB =\n  \"relative flex-1 rounded-litt-control bg-[color-mix(in_oklab,var(--litt-fg)_24%,transparent)] \" +\n  \"transition-colors duration-(--litt-dur-instant) ease-litt \" +\n  \"group-hover/bar:bg-[color-mix(in_oklab,var(--litt-fg)_40%,transparent)] group-active/bar:bg-[color-mix(in_oklab,var(--litt-fg)_40%,transparent)]\";\n\nexport type ScrollAreaProps = {\n  /** What the region holds, such as \"Build log\". It names the scrolling region, which is a Tab stop while it scrolls. */\n  label: string;\n  /** Which way it scrolls: vertical, horizontal, or both. */\n  orientation?: \"vertical\" | \"horizontal\" | \"both\";\n  /** Classes for the box. Give it a height (h-72) or a max height (max-h-96) to scroll vertically, and a width or a parent with one to scroll sideways. */\n  className?: string;\n  /** A ref to the scrolling element, to read or set its scroll position, such as starting a log at its end. */\n  viewportRef?: Ref<HTMLDivElement>;\n  /** The content. Leave a little room at the end edge (pr-3, or pb-3 sideways) so the thumb does not sit on text. */\n  children: ReactNode;\n};\n\n/** A box that scrolls natively with litt's thin thumb, and a hairline on each edge that has more beyond it. */\nexport function ScrollArea({ label, orientation = \"vertical\", className = \"\", viewportRef, children }: ScrollAreaProps) {\n  const { coarse } = useDevice();\n  const y = orientation !== \"horizontal\";\n  const x = orientation !== \"vertical\";\n\n  const viewport = useRef<HTMLDivElement | null>(null);\n  const content = useRef<HTMLDivElement | null>(null);\n  const setViewport = useCallback(\n    (el: HTMLDivElement | null) => {\n      viewport.current = el;\n      if (typeof viewportRef === \"function\") viewportRef(el);\n      else if (viewportRef) viewportRef.current = el;\n    },\n    [viewportRef],\n  );\n\n  /* Which edges have more beyond them, and whether each axis scrolls at all. */\n  const [edges, setEdges] = useState<Edges>(NONE);\n  const measure = useCallback(() => {\n    const el = viewport.current;\n    if (!el) return;\n    const maxY = el.scrollHeight - el.clientHeight;\n    const maxX = el.scrollWidth - el.clientWidth;\n    const left = Math.abs(el.scrollLeft); // right-to-left pages scroll to negative values\n    const next: Edges = {\n      top: y && el.scrollTop > 1,\n      bottom: y && el.scrollTop < maxY - 1,\n      left: x && left > 1,\n      right: x && left < maxX - 1,\n      scrollsY: y && maxY > 1,\n      scrollsX: x && maxX > 1,\n    };\n    setEdges((prev) => (same(prev, next) ? prev : next));\n  }, [x, y]);\n\n  useLayoutEffect(() => {\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    if (viewport.current) observer.observe(viewport.current);\n    if (content.current) observer.observe(content.current);\n    return () => observer.disconnect();\n  }, [measure]);\n\n  /* Armed once a person reaches for the area. Until then nothing fades and the thumb never shows. */\n  const armedRef = useRef(false);\n  const [armed, setArmed] = useState(false);\n  const arm = () => {\n    if (armedRef.current) return;\n    armedRef.current = true;\n    setArmed(true);\n  };\n\n  const [hover, setHover] = useState(false);\n  const [scrolling, setScrolling] = useState(false);\n  const hoverTimer = useRef(0);\n  const scrollTimer = useRef(0);\n  useEffect(\n    () => () => {\n      window.clearTimeout(hoverTimer.current);\n      window.clearTimeout(scrollTimer.current);\n    },\n    [],\n  );\n\n  const onScroll = () => {\n    measure();\n    if (!armedRef.current) return;\n    setScrolling(true);\n    window.clearTimeout(scrollTimer.current);\n    scrollTimer.current = window.setTimeout(() => setScrolling(false), HIDE_DELAY);\n  };\n\n  /* A mouse over the area shows the thumb. A finger has no hover, so on coarse pointers only scrolling shows it. */\n  const hovers = (e: PointerEvent) => !coarse && e.pointerType !== \"touch\";\n  const onPointerEnter = (e: PointerEvent) => {\n    if (!hovers(e)) return;\n    arm();\n    window.clearTimeout(hoverTimer.current);\n    setHover(true);\n  };\n  const onPointerLeave = (e: PointerEvent) => {\n    if (!hovers(e)) return;\n    window.clearTimeout(hoverTimer.current);\n    hoverTimer.current = window.setTimeout(() => setHover(false), HIDE_DELAY);\n  };\n\n  const shown = hover || scrolling;\n  const scrolls = edges.scrollsX || edges.scrollsY;\n  const bar = (on: boolean) =>\n    \"group/bar flex touch-none select-none p-0.5 \" + (armed ? FADE : \"\") + (on ? \"opacity-100\" : \"pointer-events-none opacity-0\");\n  const line = (on: boolean) => \"pointer-events-none absolute \" + (armed ? TINT : \"\") + (on ? \"bg-litt-line in-[[data-material=flat]]:bg-transparent\" : \"bg-transparent\");\n\n  return (\n    <ScrollAreaPrimitive.Root\n      type=\"always\"\n      className={\"relative overflow-hidden \" + className}\n      onPointerEnter={onPointerEnter}\n      onPointerLeave={onPointerLeave}\n      onWheelCapture={arm}\n      onTouchStartCapture={arm}\n      onPointerDownCapture={arm}\n      onKeyDownCapture={arm}\n      onFocusCapture={arm}\n    >\n      <ScrollAreaPrimitive.Viewport\n        ref={setViewport}\n        disableImplicitContentElement\n        onScroll={onScroll}\n        role={scrolls ? \"region\" : undefined}\n        aria-label={scrolls ? label : undefined}\n        tabIndex={scrolls ? 0 : undefined}\n        style={{ borderRadius: \"inherit\" }}\n        className=\"size-full max-h-[inherit] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\"\n      >\n        {/* Radix lays the content out as a table so it can grow sideways. Vertical content is a block, so it wraps. */}\n        <ScrollAreaPrimitive.Content ref={content} style={x ? undefined : { display: \"block\" }}>\n          {children}\n        </ScrollAreaPrimitive.Content>\n      </ScrollAreaPrimitive.Viewport>\n\n      {y && (\n        <>\n          <span aria-hidden=\"true\" className={line(edges.top) + \" inset-x-0 top-0 h-px\"} />\n          <span aria-hidden=\"true\" className={line(edges.bottom) + \" inset-x-0 bottom-0 h-px\"} />\n          <ScrollAreaPrimitive.Scrollbar orientation=\"vertical\" className={bar(shown && edges.scrollsY) + \" w-2.5\"}>\n            <ScrollAreaPrimitive.Thumb className={THUMB} />\n          </ScrollAreaPrimitive.Scrollbar>\n        </>\n      )}\n      {x && (\n        <>\n          <span aria-hidden=\"true\" className={line(edges.left) + \" inset-y-0 left-0 w-px\"} />\n          <span aria-hidden=\"true\" className={line(edges.right) + \" inset-y-0 right-0 w-px\"} />\n          <ScrollAreaPrimitive.Scrollbar orientation=\"horizontal\" className={bar(shown && edges.scrollsX) + \" h-2.5 flex-col\"}>\n            <ScrollAreaPrimitive.Thumb className={THUMB} />\n          </ScrollAreaPrimitive.Scrollbar>\n        </>\n      )}\n      {x && y && <ScrollAreaPrimitive.Corner />}\n    </ScrollAreaPrimitive.Root>\n  );\n}\n",
      "target": "components/litt/scroll-area.tsx"
    }
  ],
  "docs": "Import ScrollArea from components/litt/scroll-area. Pass a label and give it a height (or a max height) in className; set orientation to \"horizontal\" or \"both\" to scroll sideways. Pass viewportRef to read or set the scroll position.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
