{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sidenotes","type":"registry:component","title":"Sidenotes","description":"Slides footnotes into the margin beside their sentence, and folds them inline on narrow screens.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/sidenotes.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  Fragment,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n/* The teachable part: notes are laid out like a tiny column typesetter.\n   Each open note wants to sit level with its reference; walking them top to\n   bottom, a note that would collide with the one above is pushed just below\n   it. A hairline runs under the rest of the reference's line to the margin,\n   then bends to wherever its note ended up, so a pushed note is still\n   visibly tied to its sentence. */\n\nexport type Segment = string | { note: React.ReactNode };\nexport type Paragraph = Segment[];\n\n// Below this width there is no margin worth using, so notes unfold inline.\nconst WIDE_AT = 560;\n// Margin column width, and the gutter between text and margin.\nconst MARGIN = 176;\nconst GUTTER = 32;\n// Vertical breathing room between two stacked notes.\nconst STACK_GAP = 12;\n// A pushed note glides rather than jumps, so the eye can follow it; no\n// bounce, since overshooting would briefly overlap its neighbour.\nconst SETTLE = { type: \"spring\", visualDuration: 0.3, bounce: 0 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Hover must survive the trip from the reference across to the note.\nconst HOVER_GRACE = 160;\n\ntype Ref = { x: number; line: number; top: number };\n\nexport function Sidenotes({\n  paragraphs,\n  defaultOpen = [],\n  className,\n}: {\n  paragraphs: Paragraph[];\n  // Note numbers, from 1, that start open.\n  defaultOpen?: number[];\n  className?: string;\n}) {\n  const uid = useId();\n  const reduceMotion = useReducedMotion() ?? false;\n  const root = useRef<HTMLDivElement>(null);\n  const refEls = useRef<(HTMLButtonElement | null)[]>([]);\n  const noteEls = useRef<(HTMLElement | null)[]>([]);\n  const [wide, setWide] = useState(true);\n  const [pinned, setPinned] = useState<Set<number>>(() => new Set(defaultOpen.map((n) => n - 1)));\n  const [hovered, setHovered] = useState<number | null>(null);\n  const [refs, setRefs] = useState<Ref[]>([]);\n  const [tops, setTops] = useState<number[]>([]);\n  const [edge, setEdge] = useState(0);\n  // Room for notes that stack past the end of the text.\n  const [minHeight, setMinHeight] = useState(0);\n  const hoverTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  // Number every note once, in reading order.\n  const notes: React.ReactNode[] = [];\n  const numbered = paragraphs.map((p) =>\n    p.map((seg) => (typeof seg === \"string\" ? seg : { note: seg.note, n: notes.push(seg.note) - 1 })),\n  );\n\n  const isOpen = useCallback((i: number) => pinned.has(i) || hovered === i, [pinned, hovered]);\n\n  // Watch the width, and re-measure references whenever text reflows.\n  useLayoutEffect(() => {\n    const el = root.current;\n    if (!el) return;\n    const measure = () => {\n      setWide(el.offsetWidth >= WIDE_AT);\n      setEdge(el.offsetWidth - MARGIN - GUTTER);\n      const box = el.getBoundingClientRect();\n      setRefs(\n        refEls.current.map((r) => {\n          const b = r?.getBoundingClientRect();\n          if (!b) return { x: 0, line: 0, top: 0 };\n          // The hairline runs in the gap just under the reference's line.\n          return { x: b.right - box.left, line: b.bottom - box.top + 3, top: b.top - box.top };\n        }),\n      );\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Stack the open notes: each at its reference, or just under the last one.\n  useLayoutEffect(() => {\n    if (!wide) return;\n    let floor = -Infinity;\n    const next = refs.map((r, i) => {\n      if (!isOpen(i)) return r.top;\n      const top = Math.max(r.top, floor);\n      floor = top + (noteEls.current[i]?.offsetHeight ?? 0) + STACK_GAP;\n      return top;\n    });\n    setTops(next);\n    setMinHeight(Math.max(0, floor - STACK_GAP));\n  }, [refs, wide, isOpen]);\n\n  useEffect(() => () => clearTimeout(hoverTimer.current), []);\n\n  const hoverIn = (i: number) => {\n    clearTimeout(hoverTimer.current);\n    setHovered(i);\n  };\n  const hoverOut = () => {\n    clearTimeout(hoverTimer.current);\n    hoverTimer.current = setTimeout(() => setHovered(null), HOVER_GRACE);\n  };\n  const toggle = (i: number) => {\n    setPinned((prev) => {\n      const next = new Set(prev);\n      if (next.has(i)) next.delete(i);\n      else next.add(i);\n      return next;\n    });\n    // Unpinning under the pointer should close it now, not linger as a hover.\n    setHovered(null);\n  };\n\n  const textWidth = wide ? `calc(100% - ${MARGIN + GUTTER}px)` : \"100%\";\n\n  return (\n    <div\n      ref={root}\n      style={{ minHeight: wide ? minHeight : undefined }}\n      className={cn(\"relative w-full text-[15px] leading-7 text-pretty text-muted-foreground\", className)}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\") setHovered(null);\n      }}\n    >\n      <div style={{ width: textWidth }}>\n        {numbered.map((p, pi) => {\n          const inlineNotes = p.filter((s) => typeof s !== \"string\" && isOpen(s.n)) as { note: React.ReactNode; n: number }[];\n          return (\n            <Fragment key={pi}>\n              <p className={cn(pi > 0 && \"mt-4\")}>\n                {p.map((seg, si) =>\n                  typeof seg === \"string\" ? (\n                    <Fragment key={si}>{seg}</Fragment>\n                  ) : (\n                    <button\n                      key={si}\n                      ref={(el) => {\n                        refEls.current[seg.n] = el;\n                      }}\n                      type=\"button\"\n                      aria-expanded={isOpen(seg.n)}\n                      aria-controls={`${uid}-n${seg.n}`}\n                      aria-label={`Note ${seg.n + 1}`}\n                      onClick={() => toggle(seg.n)}\n                      onPointerEnter={(e) => {\n                        if (e.pointerType !== \"touch\") hoverIn(seg.n);\n                      }}\n                      onPointerLeave={(e) => {\n                        if (e.pointerType !== \"touch\") hoverOut();\n                      }}\n                      className={cn(\n                        // The glyph is tiny, so the hit area is padded out to\n                        // a comfortable target without moving the text.\n                        \"relative -top-[0.4em] mx-0.5 inline-flex h-5 min-w-5 touch-manipulation items-center justify-center rounded-full px-1 align-baseline text-[12px] leading-none font-semibold tabular-nums outline-hidden\",\n                        \"transition-[background-color,color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n                        \"before:absolute before:-inset-2 before:content-['']\",\n                        // A quiet chip at rest, so every number reads as something to\n                        // press, and solid once its note is showing.\n                        isOpen(seg.n)\n                          ? \"bg-foreground text-background\"\n                          : \"bg-foreground/[0.08] text-foreground hover:bg-foreground/15\",\n                      )}\n                    >\n                      {seg.n + 1}\n                    </button>\n                  ),\n                )}\n              </p>\n              {/* Narrow widths: the note unfolds right under its paragraph. */}\n              {!wide && (\n                <AnimatePresence initial={false}>\n                  {inlineNotes.map((s) => (\n                    <motion.aside\n                      key={s.n}\n                      id={`${uid}-n${s.n}`}\n                      // Height is animated on purpose: the paragraphs below\n                      // have to make room, and they should slide, not jump.\n                      initial={{ height: 0, opacity: 0, filter: reduceMotion ? \"none\" : \"blur(4px)\" }}\n                      animate={{ height: \"auto\", opacity: 1, filter: \"blur(0px)\", transition: { duration: 0.26, ease: EASE_OUT } }}\n                      exit={{ height: 0, opacity: 0, transition: { duration: 0.18, ease: EASE_OUT } }}\n                      className=\"overflow-hidden\"\n                    >\n                      <NoteBody n={s.n} className=\"mt-3 border-l-2 border-foreground/20 pl-3.5\">\n                        {s.note}\n                      </NoteBody>\n                    </motion.aside>\n                  ))}\n                </AnimatePresence>\n              )}\n            </Fragment>\n          );\n        })}\n      </div>\n\n      {wide && (\n        <>\n          <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full overflow-visible\">\n            {notes.map((_, i) =>\n              refs[i] ? (\n                <Hairline\n                  key={i}\n                  open={isOpen(i)}\n                  from={refs[i]}\n                  edge={edge}\n                  top={tops[i] ?? refs[i].top}\n                  reduceMotion={reduceMotion}\n                />\n              ) : null,\n            )}\n          </svg>\n          {notes.map((note, i) => (\n            <MarginNote\n              key={i}\n              id={`${uid}-n${i}`}\n              ref={(el) => {\n                noteEls.current[i] = el;\n              }}\n              n={i}\n              open={isOpen(i)}\n              top={tops[i] ?? 0}\n              reduceMotion={reduceMotion}\n              onPointerEnter={() => hoverIn(i)}\n              onPointerLeave={hoverOut}\n            >\n              {note}\n            </MarginNote>\n          ))}\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction NoteBody({ n, className, children }: { n: number; className?: string; children: React.ReactNode }) {\n  return (\n    <div className={cn(\"text-[13px] leading-5 text-muted-foreground\", className)}>\n      <span className=\"mr-1.5 font-semibold text-foreground tabular-nums\">{n + 1}</span>\n      {children}\n    </div>\n  );\n}\n\nfunction MarginNote({\n  id,\n  ref,\n  n,\n  open,\n  top,\n  reduceMotion,\n  onPointerEnter,\n  onPointerLeave,\n  children,\n}: {\n  id: string;\n  ref: (el: HTMLElement | null) => void;\n  n: number;\n  open: boolean;\n  top: number;\n  reduceMotion: boolean;\n  onPointerEnter: () => void;\n  onPointerLeave: () => void;\n  children: React.ReactNode;\n}) {\n  const y = useMotionValue(top);\n  const wasOpen = useRef(open);\n\n  useEffect(() => {\n    // Opening places the note straight at its spot; only notes already on\n    // screen glide when a neighbour pushes them.\n    if (!wasOpen.current || reduceMotion) y.jump(top);\n    else animate(y, top, SETTLE);\n    wasOpen.current = open;\n  }, [top, open, reduceMotion, y]);\n\n  useEffect(() => () => y.stop(), [y]);\n\n  return (\n    <motion.aside\n      ref={ref}\n      id={id}\n      inert={!open}\n      style={{ y, width: MARGIN }}\n      onPointerEnter={(e) => {\n        if (e.pointerType !== \"touch\") onPointerEnter();\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType !== \"touch\") onPointerLeave();\n      }}\n      className=\"absolute top-0 right-0\"\n    >\n      <div\n        className={cn(\n          \"transition-[opacity,translate,filter] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-x-0\",\n          open\n            ? \"translate-x-0 opacity-100 blur-none duration-[240ms]\"\n            : \"-translate-x-2 opacity-0 blur-[4px] duration-150\",\n        )}\n      >\n        <NoteBody n={n}>{children}</NoteBody>\n      </div>\n    </motion.aside>\n  );\n}\n\nfunction Hairline({\n  open,\n  from,\n  edge,\n  top,\n  reduceMotion,\n}: {\n  open: boolean;\n  from: Ref;\n  edge: number;\n  top: number;\n  reduceMotion: boolean;\n}) {\n  const noteY = useMotionValue(top);\n  const wasOpen = useRef(open);\n  useEffect(() => {\n    if (!wasOpen.current || reduceMotion) noteY.jump(top);\n    else animate(noteY, top, SETTLE);\n    wasOpen.current = open;\n  }, [top, open, reduceMotion, noteY]);\n  useEffect(() => () => noteY.stop(), [noteY]);\n\n  // Two pieces. A dotted leader runs in the gap under the rest of the\n  // line to the text's edge, the way a table of contents leads the eye to\n  // a page number: dots, so it never reads as underlining the next words.\n  // Then a solid stroke crosses the gutter to the note's first line,\n  // bending if the note was pushed down.\n  const connector = useConnectorPath(noteY, from, edge);\n  const show = open\n    ? { opacity: 1, transition: { duration: reduceMotion ? 0 : 0.2, ease: EASE_OUT } }\n    : { opacity: 0, transition: { duration: 0.15, ease: EASE_OUT } };\n  return (\n    <g className=\"text-foreground/35\">\n      <motion.path\n        d={`M ${from.x + 6} ${from.line} H ${edge}`}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.25}\n        strokeLinecap=\"round\"\n        strokeDasharray=\"0 4\"\n        initial={false}\n        animate={show}\n      />\n      <motion.path\n        d={connector}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1}\n        strokeLinecap=\"round\"\n        initial={false}\n        animate={\n          open\n            ? { pathLength: 1, opacity: 1, transition: { duration: reduceMotion ? 0 : 0.28, ease: EASE_OUT } }\n            : { pathLength: 0, opacity: 0, transition: { duration: 0.15, ease: EASE_OUT } }\n        }\n      />\n    </g>\n  );\n}\n\nfunction useConnectorPath(noteY: MotionValue<number>, from: Ref, edge: number) {\n  return useTransform(noteY, (top) => {\n    const endY = top + 10;\n    const bend = edge + GUTTER * 0.45;\n    return `M ${edge} ${from.line} C ${bend} ${from.line} ${bend} ${endY} ${edge + GUTTER - 6} ${endY}`;\n  });\n}\n\n/* A blog post excerpt with three notes. */\n\nconst PARAGRAPHS: Paragraph[] = [\n  [\n    \"The first printed books kept their notes in the margin, right where the reader needed them.\",\n    { note: \"Glossed manuscripts did the same a thousand years earlier, often in a second, smaller hand crowded around the main text.\" },\n    \" Footnotes only took over when typesetting got fussy about columns, and endnotes arrived when publishers decided notes were too expensive to set on the page at all.\",\n  ],\n  [\n    \"On the web we inherited the worst of both: a tiny number that jumps you to the bottom of the page and a back link you have to hunt for.\",\n    { note: \"Wikipedia's hover previews, added in 2018, were the first mainstream fix. They cut clicks on reference links by a large margin.\" },\n    \" Margins are free on a wide screen, so notes can live beside the sentence they belong to, the way Edward Tufte sets them in his books.\",\n    { note: \"Tufte's sidenotes need a wide text block. Below that, they fold back into the text, which is exactly what this one does.\" },\n    \" Hover a number to peek, click to keep it open.\",\n  ],\n  [\n    \"None of this needs a new format. The notes are still written inline, where the author thought of them, and the page decides how to show them from the room it has.\",\n  ],\n];\n\nexport default function SidenotesDemo() {\n  return (\n    <article className=\"w-[640px] max-w-full\">\n      <p className=\"text-[13px] text-muted-foreground\">Essay · 4 min read</p>\n      <h2 className=\"mt-1 mb-4 text-xl font-semibold text-balance text-foreground\">\n        Put the notes where the reader is\n      </h2>\n      <Sidenotes paragraphs={PARAGRAPHS} defaultOpen={[1]} />\n    </article>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["text"],"docs":"From ui lab: https://lab.xevrion.dev/lab/sidenotes"}