{
  "name": "scroll-index",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Scroll Index\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type KeyboardEvent,\n  type ReactNode,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ScrollIndexProps {\n  /** The content to index. Defaults to a sample article. */\n  children?: ReactNode;\n  /** Which headings get a tick. */\n  selector?: string;\n  /**\n   * `self`: the component scrolls its own box (default).\n   * `window`: the page scrolls and the rail sticks beside the content.\n   */\n  scroller?: \"self\" | \"window\";\n  className?: string;\n}\n\ninterface Section {\n  el: HTMLElement;\n  text: string;\n  level: number;\n  /** Scroll position that brings the heading to the top. */\n  at: number;\n  /** Where its tick sits on the rail, 0 to 1. */\n  pos: number;\n}\n\n// The rail is this tall at most, in px; ticks sit at the scroll position of\n// their heading, so long sections leave long gaps.\nconst RAIL = 280;\n// Headings land this far below the top edge when jumped to.\nconst OFFSET = 16;\n// Ticks swell near the pointer like a dock: peak width and reach, in px.\nconst SWELL = 1.6;\nconst REACH = 26;\n// A drag let go this close to a tick (after its momentum) settles on it.\nconst SNAP = 18;\n// How long the flashed section name stays up after scrolling into it.\nconst FLASH = 900;\n\nfunction easeInOut(t: number) {\n  return t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2;\n}\n\nexport default function ScrollIndex({\n  children,\n  selector = \"h2, h3\",\n  scroller = \"self\",\n  className,\n}: ScrollIndexProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const railRef = useRef<HTMLDivElement>(null);\n  const headRef = useRef<HTMLSpanElement>(null);\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const tickRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [sections, setSections] = useState<\n    { text: string; level: number; pos: number }[]\n  >([]);\n  // The effect below reads and writes these without re-rendering.\n  const api = useRef<{\n    jump: (index: number) => void;\n    show: (i: number) => void;\n  }>({ jump: () => undefined, show: () => undefined });\n\n  useEffect(() => {\n    const box = boxRef.current;\n    const content = contentRef.current;\n    const rail = railRef.current;\n    const head = headRef.current;\n    const label = labelRef.current;\n    if (!(box && content && rail && head && label)) {\n      return;\n    }\n    const usesWindow = scroller === \"window\";\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    let list: Section[] = [];\n    let start = 0;\n    let end = 1;\n    let active = -1;\n    let height = RAIL;\n    let raf = 0;\n    let tween = 0;\n    let flashTimer = 0;\n    let painted = false;\n    let hover: number | null = null;\n    let drag: {\n      id: number;\n      moved: boolean;\n      y: number;\n      t: number;\n      v: number;\n    } | null = null;\n    const swell: number[] = [];\n\n    const getScroll = () => (usesWindow ? window.scrollY : box.scrollTop);\n    const setScroll = (value: number) => {\n      if (usesWindow) {\n        window.scrollTo(0, value);\n      } else {\n        box.scrollTop = value;\n      }\n    };\n\n    const measure = () => {\n      const scroll = getScroll();\n      if (usesWindow) {\n        const top = content.getBoundingClientRect().top + scroll;\n        start = Math.max(top - OFFSET, 0);\n        end = Math.max(\n          top + content.offsetHeight - window.innerHeight,\n          start + 1\n        );\n      } else {\n        start = 0;\n        end = Math.max(box.scrollHeight - box.clientHeight, 1);\n      }\n      const origin = usesWindow ? 0 : box.getBoundingClientRect().top - scroll;\n      list = Array.from(content.querySelectorAll<HTMLElement>(selector)).map(\n        (el) => {\n          const at = Math.min(\n            Math.max(\n              el.getBoundingClientRect().top + scroll - origin - OFFSET,\n              start\n            ),\n            end\n          );\n          return {\n            el,\n            text: el.textContent?.trim() ?? \"\",\n            level: Number(el.tagName.slice(1)) || 2,\n            at,\n            pos: (at - start) / (end - start),\n          };\n        }\n      );\n      height = rail.clientHeight || RAIL;\n      setSections(list.map(({ text, level, pos }) => ({ text, level, pos })));\n    };\n\n    const fraction = () =>\n      Math.min(Math.max((getScroll() - start) / (end - start), 0), 1);\n\n    const nearest = (y: number) => {\n      let best = -1;\n      let distance = Number.POSITIVE_INFINITY;\n      list.forEach((s, i) => {\n        const d = Math.abs(s.pos * height - y);\n        if (d < distance) {\n          distance = d;\n          best = i;\n        }\n      });\n      return { index: best, distance };\n    };\n\n    const placeLabel = (index: number, visible: boolean) => {\n      const section = list[index];\n      if (!section) {\n        label.style.opacity = \"0\";\n        return;\n      }\n      if (label.textContent !== section.text) {\n        label.textContent = section.text;\n      }\n      label.style.transform = `translate3d(0, ${(section.pos * height).toFixed(1)}px, 0) translateY(-50%)`;\n      label.style.opacity = visible ? \"1\" : \"0\";\n    };\n\n    /** Section the reader is in: the last heading at or above the top. */\n    const current = () => {\n      const scroll = getScroll() + 1;\n      let index = -1;\n      for (let i = 0; i < list.length; i++) {\n        if (list[i].at <= scroll) {\n          index = i;\n        }\n      }\n      return index;\n    };\n\n    const paint = () => {\n      raf = 0;\n      head.style.transform = `translate3d(0, ${(fraction() * height).toFixed(1)}px, 0)`;\n      const now = current();\n      if (now !== active) {\n        active = now;\n        tickRefs.current.forEach((tick, i) => {\n          if (tick) {\n            // The section being read is marked apart from the ones passed.\n            let state = \"ahead\";\n            if (i === active) {\n              state = \"current\";\n            } else if (i < active) {\n              state = \"passed\";\n            }\n            tick.dataset.state = state;\n          }\n        });\n        // Scrolling into a new section names it briefly.\n        if (painted && hover === null && !drag && active >= 0) {\n          placeLabel(active, true);\n          window.clearTimeout(flashTimer);\n          flashTimer = window.setTimeout(() => {\n            if (hover === null && !drag) {\n              label.style.opacity = \"0\";\n            }\n          }, FLASH);\n        }\n      }\n      // Ticks ease toward their swell, so the magnification follows the\n      // pointer smoothly instead of jumping.\n      let moving = false;\n      tickRefs.current.forEach((tick, i) => {\n        if (!tick) {\n          return;\n        }\n        const y = (list[i]?.pos ?? 0) * height;\n        const target =\n          hover === null || reduceMotion\n            ? 1\n            : 1 + SWELL * Math.exp(-((hover - y) ** 2) / (2 * REACH * REACH));\n        const value = (swell[i] ?? 1) + (target - (swell[i] ?? 1)) * 0.35;\n        swell[i] = Math.abs(value - target) < 0.005 ? target : value;\n        moving ||= swell[i] !== target;\n        tick.style.setProperty(\"--swell\", swell[i].toFixed(3));\n      });\n      painted = true;\n      if (moving) {\n        schedule();\n      }\n    };\n\n    function schedule() {\n      if (!raf) {\n        raf = requestAnimationFrame(paint);\n      }\n    }\n\n    const stopTween = () => {\n      cancelAnimationFrame(tween);\n      tween = 0;\n    };\n\n    const scrollTo = (target: number) => {\n      stopTween();\n      const from = getScroll();\n      const to = Math.min(Math.max(target, 0), usesWindow ? 1e9 : end);\n      if (reduceMotion || Math.abs(to - from) < 2) {\n        setScroll(to);\n        return;\n      }\n      const duration = Math.min(\n        Math.max(260 + Math.abs(to - from) * 0.12, 300),\n        650\n      );\n      const begin = performance.now();\n      const step = (now: number) => {\n        const t = Math.min((now - begin) / duration, 1);\n        setScroll(from + (to - from) * easeInOut(t));\n        tween = t < 1 ? requestAnimationFrame(step) : 0;\n      };\n      tween = requestAnimationFrame(step);\n    };\n\n    api.current = {\n      jump: (index) => {\n        const section = list[index];\n        if (section) {\n          scrollTo(section.at);\n        }\n      },\n      show: (index) => {\n        hover = (list[index]?.pos ?? 0) * height;\n        placeLabel(index, true);\n        schedule();\n      },\n    };\n\n    const railY = (event: PointerEvent) =>\n      Math.min(\n        Math.max(event.clientY - rail.getBoundingClientRect().top, 0),\n        height\n      );\n\n    const scrub = (y: number) =>\n      setScroll(start + (y / height) * (end - start));\n\n    const onDown = (event: PointerEvent) => {\n      if (drag || event.button !== 0) {\n        return;\n      }\n      event.preventDefault();\n      rail.setPointerCapture(event.pointerId);\n      stopTween();\n      const y = railY(event);\n      drag = { id: event.pointerId, moved: false, y, t: event.timeStamp, v: 0 };\n      hover = y;\n      rail.dataset.dragging = \"\";\n      placeLabel(nearest(y).index, true);\n      schedule();\n    };\n\n    const onMove = (event: PointerEvent) => {\n      const y = railY(event);\n      if (drag && event.pointerId === drag.id) {\n        const dt = Math.max(event.timeStamp - drag.t, 1);\n        drag.v = drag.v * 0.4 + ((y - drag.y) / dt) * 0.6;\n        drag.moved ||= Math.abs(y - drag.y) > 3;\n        drag.y = y;\n        drag.t = event.timeStamp;\n        if (drag.moved) {\n          // Dragging the rail scrubs the page under it.\n          scrub(y);\n        }\n      } else if (event.pointerType !== \"mouse\") {\n        return;\n      }\n      hover = y;\n      placeLabel(nearest(y).index, true);\n      schedule();\n    };\n\n    const onUp = (event: PointerEvent) => {\n      if (!drag || event.pointerId !== drag.id) {\n        return;\n      }\n      const { moved, y, v } = drag;\n      drag = null;\n      delete rail.dataset.dragging;\n      if (event.pointerType !== \"mouse\") {\n        hover = null;\n        placeLabel(-1, false);\n      }\n      // Where the flick was heading, then the tick nearest that.\n      const projected = moved ? y + v * 120 : y;\n      const hit = nearest(projected);\n      if (hit.index >= 0 && (!moved || hit.distance < SNAP)) {\n        api.current.jump(hit.index);\n      }\n      schedule();\n    };\n\n    const onLeave = () => {\n      if (drag) {\n        return;\n      }\n      hover = null;\n      placeLabel(-1, false);\n      schedule();\n    };\n\n    const scrollTarget: HTMLElement | Window = usesWindow ? window : box;\n    const onScroll = () => schedule();\n    // Any scroll input of the reader's own cancels a jump in progress.\n    const onInput = () => stopTween();\n\n    measure();\n    paint();\n    const resize = new ResizeObserver(() => {\n      measure();\n      schedule();\n    });\n    resize.observe(content);\n    resize.observe(box);\n    scrollTarget.addEventListener(\"scroll\", onScroll, { passive: true });\n    scrollTarget.addEventListener(\"wheel\", onInput, { passive: true });\n    scrollTarget.addEventListener(\"touchstart\", onInput, { passive: true });\n    rail.addEventListener(\"pointerdown\", onDown);\n    rail.addEventListener(\"pointermove\", onMove);\n    rail.addEventListener(\"pointerup\", onUp);\n    rail.addEventListener(\"pointercancel\", onUp);\n    rail.addEventListener(\"pointerleave\", onLeave);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      stopTween();\n      window.clearTimeout(flashTimer);\n      resize.disconnect();\n      scrollTarget.removeEventListener(\"scroll\", onScroll);\n      scrollTarget.removeEventListener(\"wheel\", onInput);\n      scrollTarget.removeEventListener(\"touchstart\", onInput);\n      rail.removeEventListener(\"pointerdown\", onDown);\n      rail.removeEventListener(\"pointermove\", onMove);\n      rail.removeEventListener(\"pointerup\", onUp);\n      rail.removeEventListener(\"pointercancel\", onUp);\n      rail.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [selector, scroller]);\n\n  const onTickKey = (index: number, event: KeyboardEvent) => {\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") {\n      return;\n    }\n    event.preventDefault();\n    const next = Math.min(\n      Math.max(index + (event.key === \"ArrowDown\" ? 1 : -1), 0),\n      sections.length - 1\n    );\n    tickRefs.current[next]?.focus();\n  };\n\n  const self = scroller === \"self\";\n\n  return (\n    <div\n      className={cn(\n        \"relative w-full\",\n        self &&\n          \"h-[520px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950\",\n        !self && \"grid grid-cols-[minmax(0,1fr)_3rem] gap-6\",\n        className\n      )}\n    >\n      <div\n        className={cn(self && \"h-full overflow-y-auto overscroll-contain\")}\n        ref={boxRef}\n      >\n        <div\n          className={cn(self && \"py-8 pr-16 pl-6 sm:pl-10\")}\n          ref={contentRef}\n        >\n          {children ?? <SampleArticle />}\n        </div>\n      </div>\n\n      <nav\n        aria-label=\"Sections\"\n        className={cn(\n          \"z-10 flex w-12 justify-end\",\n          self\n            ? \"absolute top-1/2 right-3 -translate-y-1/2\"\n            : \"sticky top-[calc(50vh-140px)] h-fit self-start\"\n        )}\n      >\n        <div\n          className=\"relative w-full cursor-pointer touch-none select-none data-[dragging]:cursor-grabbing\"\n          ref={railRef}\n          style={{ height: RAIL }}\n        >\n          {sections.map((section, index) => (\n            <button\n              aria-label={section.text}\n              className={cn(\n                \"absolute right-0 h-4 -translate-y-1/2 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-zinc-400\",\n                section.level <= 2 ? \"w-3.5\" : \"w-2\"\n              )}\n              data-state=\"ahead\"\n              key={`${index}-${section.text}`}\n              onBlur={() => labelRef.current?.style.setProperty(\"opacity\", \"0\")}\n              onClick={(event) => {\n                // Pointer clicks are handled by the rail; this is the keyboard.\n                if (event.detail === 0) {\n                  api.current.jump(index);\n                }\n              }}\n              onFocus={() => api.current.show(index)}\n              onKeyDown={(event) => onTickKey(index, event)}\n              ref={(el) => {\n                tickRefs.current[index] = el;\n              }}\n              style={{ top: `${(section.pos * 100).toFixed(3)}%` }}\n              tabIndex={0}\n              type=\"button\"\n            >\n              <span className=\"absolute top-1/2 right-0 h-[1.5px] w-full origin-right -translate-y-1/2 scale-x-[var(--swell,1)] rounded-full bg-zinc-300 in-data-[state=current]:bg-blue-500 in-data-[state=passed]:bg-zinc-900 transition-colors duration-200 ease-out dark:bg-zinc-700 dark:in-data-[state=current]:bg-blue-400 dark:in-data-[state=passed]:bg-zinc-100\" />\n            </button>\n          ))}\n          {/* Where the reader is, between ticks too. */}\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-0 right-0 h-[2px] w-6 -translate-y-1/2 rounded-full bg-zinc-900 will-change-transform dark:bg-zinc-100\"\n            ref={headRef}\n          />\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-0 right-9 whitespace-nowrap rounded-md border border-zinc-200 bg-white px-2 py-1 font-medium text-xs text-zinc-700 opacity-0 shadow-sm transition-[opacity,transform] duration-150 ease-out dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200\"\n            ref={labelRef}\n          />\n        </div>\n      </nav>\n    </div>\n  );\n}\n\nfunction SampleArticle() {\n  const p = \"mt-3 text-[15px] text-zinc-600 leading-relaxed dark:text-zinc-400\";\n  const h2 =\n    \"mt-12 font-semibold text-xl text-zinc-900 tracking-tight first:mt-0 dark:text-zinc-100\";\n  const h3 = \"mt-8 font-medium text-base text-zinc-900 dark:text-zinc-100\";\n  return (\n    <article className=\"mx-auto max-w-xl\">\n      <p className=\"font-medium text-xs text-zinc-400 dark:text-zinc-500\">\n        Field notes · 8 min read\n      </p>\n      <h2 className={cn(h2, \"mt-2 text-2xl\")}>Building a component library</h2>\n      <p className={p}>\n        A component library is a promise: that the next screen will be faster to\n        build than the last, and that it will feel like the same product. These\n        notes cover what we learned keeping that promise for two years.\n      </p>\n      <p className={p}>\n        Most of it is about saying no. Every prop is a decision someone else has\n        to make, and every variant is a screen that drifts.\n      </p>\n      <h2 className={h2}>Principles</h2>\n      <p className={p}>\n        Three rules settled most arguments before they started. Write them down\n        early, and point to them often.\n      </p>\n      <h3 className={h3}>Restraint</h3>\n      <p className={p}>\n        Ship the smallest component that solves the problem in front of you. A\n        button with four sizes is easier to live with than one with nine, and\n        nobody has ever asked for the fifth.\n      </p>\n      <h3 className={h3}>Motion with a purpose</h3>\n      <p className={p}>\n        Animation explains where something came from and where it went. Anything\n        else is decoration, and decoration ages quickly. Keep interface motion\n        under 300 ms and let people interrupt it.\n      </p>\n      <h2 className={h2}>Tokens</h2>\n      <p className={p}>\n        Tokens are the vocabulary. Color, type, spacing and radius live in one\n        place, so a rebrand is a pull request rather than a quarter.\n      </p>\n      <h3 className={h3}>Color</h3>\n      <p className={p}>\n        Name colors by job, not by hue. A surface, a border and a muted label\n        survive a palette change; a zinc-150 never does.\n      </p>\n      <h3 className={h3}>Type</h3>\n      <p className={p}>\n        Two families and five sizes cover nearly everything. Line height is part\n        of the size, never a separate choice.\n      </p>\n      <h2 className={h2}>Components</h2>\n      <p className={p}>\n        Build from the inside out: primitives first, then the pieces that\n        compose them. A dialog is a portal, a focus trap and a surface before it\n        is ever a confirmation box.\n      </p>\n      <p className={p}>\n        Keep the API close to the platform. If it behaves like a button, it is a\n        button element, with every attribute a button takes.\n      </p>\n      <h2 className={h2}>Accessibility</h2>\n      <p className={p}>\n        Keyboard first. If a component can't be used without a mouse, it is not\n        finished. Test with a screen reader before review, not after launch, and\n        treat reduced motion as a real setting.\n      </p>\n      <h2 className={h2}>Shipping</h2>\n      <p className={p}>\n        Release small and often. A changelog people actually read is worth more\n        than a migration guide nobody finds.\n      </p>\n      <p className={p}>\n        Every release gets a short note: what changed, why, and what to do about\n        it, in that order.\n      </p>\n      <h2 className={h2}>What's next</h2>\n      <p className={p}>\n        Theming per product, a token pipeline that feeds design and code from\n        the same file, and fewer components, not more.\n      </p>\n      <p className={cn(p, \"pb-40\")}>\n        Thanks for reading. Drag the rail on the right to move through these\n        notes, or hover it to see where each section starts.\n      </p>\n    </article>\n  );\n}\n",
      "path": "/components/kokonutui/scroll-index.tsx",
      "target": "components/kokonutui/scroll-index.tsx"
    }
  ]
}