{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scroll-anchor","type":"registry:ui","title":"ScrollAnchor","description":"A scroll region that stays pinned to its last line while content streams in, lets go when the reader scrolls up, and floats a pill offering the way back down","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-chat"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/scroll-anchor/ScrollAnchor.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { scrollToBottom, useAutoscroll } from \"../../internals/use-autoscroll\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./scroll-anchor.css\";\n\n/**\n * Props for ScrollAnchor\n */\nexport interface ScrollAnchorProps {\n\t/**\n\t * Whether the region pins itself to the bottom as content arrives. Bind it\n\t * to whatever says a response is still streaming; `false` leaves an ordinary\n\t * scroll box that never moves on its own.\n\t */\n\tactive?: boolean;\n\t/** How close to the bottom (px) still counts as pinned. */\n\tbottomThreshold?: number;\n\t/** Label on the floating return button. */\n\treturnLabel?: string;\n\t/** Whether the floating return button appears once the reader scrolls away. */\n\tshowReturn?: boolean;\n\t/** Height cap on the scrolling region — any CSS length. */\n\tmaxHeight?: string;\n\t/** Called when the region pins itself to the bottom or lets go, never on every scroll. */\n\tonStickChange?: (stuck: boolean) => void;\n\t/** The scrolling content */\n\tchildren: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the press cue through the sound controller when the\n\t * return-to-latest pill is activated. Off by default; only audible\n\t * once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nfunction prefersReducedMotion(): boolean {\n\tif (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n\treturn window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n}\n\nexport const ScrollAnchor = forwardRef<HTMLDivElement, ScrollAnchorProps>(function ScrollAnchor(\n\t{\n\t\tactive = true,\n\t\tbottomThreshold = 40,\n\t\treturnLabel = \"Jump to latest\",\n\t\tshowReturn = true,\n\t\tmaxHeight = \"100%\",\n\t\tonStickChange,\n\t\tchildren,\n\t\tclassName,\n\t\tsound = false,\n\t},\n\tref\n) {\n\tconst [region, regionRef] = useElementRef<HTMLDivElement>();\n\t// Content that fits, or a container already at its bottom edge, counts as\n\t// pinned — the same opening assumption the autoscroll core makes.\n\tconst [stuck, setStuck] = useState(true);\n\n\tconst playCue = useSoundCue(sound);\n\n\t/**\n\t * The autoscroll core is the one that decides; this only mirrors its answer\n\t * so the button can be rendered from it, and passes the same answer on\n\t * untouched.\n\t */\n\tfunction handleStick(next: boolean) {\n\t\tsetStuck(next);\n\t\tonStickChange?.(next);\n\t}\n\n\tuseAutoscroll(region, {\n\t\tenabled: active,\n\t\tbottomThreshold,\n\t\tonStickChange: handleStick,\n\t});\n\n\t/*\n\t * The button belongs to the pinning behaviour, so it goes when the pinning\n\t * does: with `active` false the core has disconnected its scroll listener\n\t * and would never be able to tell us the reader had come back down, leaving a\n\t * pill on screen that nothing could ever dismiss.\n\t */\n\tconst showPill = showReturn && active && !stuck;\n\n\t// The core reads the container once when it is attached and then only speaks\n\t// up on a transition, so a region that mounts already overflowing — a\n\t// transcript rendered in one go — would otherwise show no way down to the end.\n\t// Reading the same geometry here keeps the two in step from the first frame.\n\t// `bottomThreshold` is in the deps on purpose: it changes the answer with\n\t// nothing having scrolled, so the pill has to be recomputed when it does.\n\t// `stuck` is only written here, never read, so this cannot re-trigger itself.\n\tuseEffect(() => {\n\t\tif (!region) return;\n\t\tsetStuck(region.scrollHeight - region.scrollTop - region.clientHeight <= bottomThreshold);\n\t}, [region, bottomThreshold]);\n\n\tfunction jump() {\n\t\tif (!region) return;\n\t\tplayCue(\"press\");\n\t\t// The button is about to unmount under the pointer, which would drop the\n\t\t// keyboard back to the document body. Focus goes to the region it just\n\t\t// scrolled, so the arrow keys carry on where the button left off.\n\t\tregion.focus({ preventScroll: true });\n\t\t// A journey the reader asked for is worth showing — unless they have asked\n\t\t// for no journeys at all, in which case they simply arrive.\n\t\tscrollToBottom(region, prefersReducedMotion() ? \"instant\" : \"smooth\");\n\t}\n\n\treturn (\n\t\t/*\n\t\t * `h-full` so the region's default `max-height: 100%` has something\n\t\t * definite to resolve against: a percentage against a parent of\n\t\t * `height: auto` computes to no cap at all. With no bounded ancestor this\n\t\t * still resolves to `auto`, so nothing changes for a wrapper that was\n\t\t * sizing itself.\n\t\t */\n\t\t<div ref={ref} className={cn(\"ft-scrollanchor relative h-full\", className)}>\n\t\t\t{/*\n\t\t\t\t`tabIndex={-1}` is there for the return button alone: the region takes\n\t\t\t\tfocus when the button that had it unmounts, and stays out of the tab\n\t\t\t\torder the rest of the time, so nothing about a consumer's tab sequence\n\t\t\t\tchanges. The ring is drawn inset because the region fills the root edge\n\t\t\t\tto edge.\n\t\t\t*/}\n\t\t\t<div\n\t\t\t\tref={regionRef}\n\t\t\t\tclassName=\"ft-scrollanchor-region focus-visible:ring-ring overflow-y-auto overscroll-y-contain rounded-[inherit] focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset\"\n\t\t\t\tstyle={{ maxHeight }}\n\t\t\t\ttabIndex={-1}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</div>\n\n\t\t\t{showPill && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"ft-scrollanchor-return border-border bg-background text-foreground focus-visible:ring-ring cursor-pointer rounded-full border px-3 py-1.5 text-xs font-medium whitespace-nowrap shadow-md focus-visible:ring-2 focus-visible:outline-none\"\n\t\t\t\t\tonClick={jump}\n\t\t\t\t>\n\t\t\t\t\t<svg\n\t\t\t\t\t\tclassName=\"size-3.5 flex-none\"\n\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\tstrokeWidth=\"2.5\"\n\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<path d=\"M12 5v14\" />\n\t\t\t\t\t\t<path d=\"m19 12-7 7-7-7\" />\n\t\t\t\t\t</svg>\n\t\t\t\t\t{returnLabel}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/scroll-anchor/ScrollAnchor.tsx"},{"path":"react/src/components/scroll-anchor/index.ts","content":"\"use client\";\n\nexport { ScrollAnchor } from \"./ScrollAnchor\";\nexport type { ScrollAnchorProps } from \"./ScrollAnchor\";\n","type":"registry:ui","target":"@ui/fancy/components/scroll-anchor/index.ts"},{"path":"react/src/components/scroll-anchor/scroll-anchor.css","content":"/*\n * Centring lives here rather than on a `-translate-x-1/2` utility: the\n * entrance keyframes below animate `transform`, and a utility writing the\n * separate `translate` property would compose with it into a double shift.\n */\n.ft-scrollanchor-return {\n\tposition: absolute;\n\tbottom: var(--ft-scrollanchor-offset, 0.75rem);\n\tleft: 50%;\n\tz-index: 10;\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 0.375rem;\n\ttransform: translateX(-50%);\n}\n\n/*\n * The only thing that moves. Reduced motion gets the same button in the same\n * place, simply already there.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-scrollanchor-return {\n\t\tanimation: ft-scrollanchor-in 180ms cubic-bezier(0.4, 0, 0.2, 1) both;\n\t}\n}\n\n@keyframes ft-scrollanchor-in {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translate(-50%, 0.375rem);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: translate(-50%, 0);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/scroll-anchor/scroll-anchor.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/scroll-anchor?framework=react"}}