{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sticky-scroll","type":"registry:ui","title":"StickyScroll","description":"Two-column scroll narrative with a scrolling item list and a sticky panel that swaps its content to the item centred in view, stacking to a single top-sticky panel over the items on narrow containers","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["layout"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/sticky-scroll/StickyScroll.tsx","content":"\"use client\";\n\nimport { forwardRef, memo, useRef, useState } from \"react\";\nimport type { ForwardedRef, HTMLAttributes, ReactElement, ReactNode, RefAttributes } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useEventCallback } from \"../../internals/dom/use-event-callback\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { observeInView } from \"../../internals/motion/in-view\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport { preset } from \"../../internals/motion/transitions\";\nimport { DURATIONS } from \"../../internals/motion/tokens\";\nimport \"./sticky-scroll.css\";\n\ninterface BaseProps<T> {\n\t/** The items rendered down the scrolling column, one `item` render per row. */\n\titems: T[];\n\t/** Renders one row. `(item, index, active)` — `active` is true only for the current `activeIndex`. */\n\titem: (item: T, index: number, active: boolean) => ReactNode;\n\t/** Renders the sticky panel's content for the active item. `(item, index)`. */\n\tpanel: (item: T, index: number) => ReactNode;\n\t/**\n\t * The currently active item's index. Holds its last value when nothing\n\t * intersects the centre line. Passing it makes the index controlled — the\n\t * React counterpart of the Svelte source's `$bindable`; omit it entirely to\n\t * let the component own the index and report every change through `onChange`.\n\t */\n\tactiveIndex?: number;\n\t/** Which logical side the sticky panel sits on. Flips physically under `dir=\"rtl\"` — nothing extra to configure. */\n\tpanelSide?: \"start\" | \"end\";\n\t/** Whether the panel crossfades between items. Effective value is always `false` under reduced motion. */\n\tcrossfade?: boolean;\n\t/** Additional CSS classes for the sticky panel wrapper. */\n\tpanelClass?: string;\n\t/** Whether the panel is `aria-hidden` — the default, since it normally mirrors an already-visible active item. Set `false` when the panel holds content found nowhere else. */\n\tpanelHidden?: boolean;\n\t/** Called when the active index changes — never on every scroll tick, only on an actual change. */\n\tonChange?: (index: number, item: T) => void;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\n/**\n * Props for StickyScroll\n */\nexport interface StickyScrollProps<T>\n\textends Omit<HTMLAttributes<HTMLDivElement>, keyof BaseProps<unknown> | \"children\">,\n\t\tBaseProps<T> {}\n\n// The library's ONE bidirectional preset, reused here for the panel\n// crossfade — keeps the timing/easing sourced from the same tokens as every\n// other component in the family instead of a second, disconnected\n// 400ms/linear default. `usePresence` hands it a real \"in\"/\"out\" direction\n// per leg, exactly as the source's separate `in:`/`out:` directives did.\nconst fadePanel = preset(\"fade\");\n\ninterface StickyScrollItemProps<T> {\n\tindex: number;\n\tactive: boolean;\n\t/** The row's own item, and the consumer's renderer for it. The row calls\n\t *  the renderer itself rather than receiving a ready-made element, so a\n\t *  parent re-render that changes nothing about this row leaves its props\n\t *  identical and the memo below holds. */\n\tvalue: T;\n\titem: (item: T, index: number, active: boolean) => ReactNode;\n\tonActivate: (index: number) => void;\n}\n\n/**\n * One row of the scrolling column. A child component because the observer is\n * per-node (`use:inView` was applied per `<section>` in the source), and a\n * hook cannot be called in a loop.\n */\nfunction StickyScrollItemImpl<T>({\n\tindex,\n\tactive,\n\tvalue,\n\titem,\n\tonActivate,\n}: StickyScrollItemProps<T>) {\n\t// Convention C-1: the primitive takes the NODE, published by `useElementRef`.\n\tconst [node, ref] = useElementRef<HTMLElement>();\n\n\t// The framework-free core rather than the `useInView` hook: this row never\n\t// reads the visible state, and the hook publishes it as component state —\n\t// so the leaving fire (which deliberately does NOT move the active index)\n\t// would still commit a render producing identical DOM. The source's\n\t// `use:inView` action holds no reactive state at all.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tif (!node) return;\n\t\tconst handle = observeInView(node, {\n\t\t\tonce: false,\n\t\t\tthreshold: 0,\n\t\t\trootMargin: \"-50% 0px -50% 0px\",\n\t\t\tonChange: (visible) => {\n\t\t\t\tif (visible) onActivate(index);\n\t\t\t},\n\t\t});\n\t\treturn () => {\n\t\t\thandle.destroy();\n\t\t};\n\t}, [node, index, onActivate]);\n\n\treturn (\n\t\t<section\n\t\t\tref={ref}\n\t\t\tclassName=\"ft-stickyscroll-item\"\n\t\t\tdata-index={index}\n\t\t\tdata-active={active}\n\t\t\t// React's `onFocus` is delegated through `focusin`, so it bubbles\n\t\t\t// from descendants exactly like the source's `onfocusin`.\n\t\t\tonFocus={() => onActivate(index)}\n\t\t>\n\t\t\t{item(value, index, active)}\n\t\t</section>\n\t);\n}\n\n/**\n * An activation flips `data-active` on exactly two rows; the source re-runs\n * exactly those two snippets. Memoising the row buys React the same update\n * volume — every other row's props (`value`, `item`, `index`, `active` and the\n * identity-stable `onActivate`) are unchanged, so the consumer's `item` is not\n * re-invoked for them. `memo` erases a render function's type parameter the\n * way `forwardRef` does, so the generic call signature is restored by\n * assertion.\n */\nconst StickyScrollItem = memo(StickyScrollItemImpl) as typeof StickyScrollItemImpl;\n\ninterface PanelFrameProps {\n\t/** False the instant this frame starts its exit. */\n\topen: boolean;\n\t/** True for every frame created by an index change; false only for the\n\t *  frame present on the very first render, matching the source rule that a\n\t *  local transition never plays on the initial render of its block. */\n\tappear: boolean;\n\t/** Read at leg start. `0` is the sampler's own synchronous fast path —\n\t *  reduced motion and `crossfade={false}` both collapse to it, no separate\n\t *  branch needed. */\n\tduration: number;\n\tonExited: () => void;\n\tchildren?: ReactNode;\n}\n\n/**\n * One keyed panel frame — the counterpart of the source's `{#key panelIndex}`\n * block with `in:`/`out:` on its single child. `usePresence` keeps the node\n * mounted through the whole exit and reports back when the outro has landed,\n * at which point the parent drops the frame from its list.\n */\nfunction PanelFrame({ open, appear, duration, onExited, children }: PanelFrameProps) {\n\tconst presence = usePresence(open, { appear, onExitEnd: onExited });\n\n\tif (!presence.mounted) return null;\n\n\treturn (\n\t\t<div ref={presence.register(fadePanel, { duration })} className=\"ft-stickyscroll-panel-frame\">\n\t\t\t{children}\n\t\t</div>\n\t);\n}\n\ninterface FrameEntry {\n\tkey: number;\n\t/** True for the single current frame; false while exiting. */\n\tlive: boolean;\n\t/** An exiting frame's content, captured from the render BEFORE the index\n\t *  changed — the React counterpart of Svelte freezing the outgoing keyed\n\t *  block's DOM, and what keeps a stale index from re-reading a shrunk\n\t *  `items` array. `null` on the live frame, which renders live content. */\n\tfrozen: ReactNode;\n\tappear: boolean;\n}\n\nfunction StickyScrollImpl<T>(\n\tprops: StickyScrollProps<T>,\n\tforwardedRef: ForwardedRef<HTMLDivElement>\n) {\n\tconst {\n\t\titems,\n\t\titem,\n\t\tpanel,\n\t\tactiveIndex: activeIndexProp,\n\t\tpanelSide = \"end\",\n\t\tcrossfade = true,\n\t\tpanelClass,\n\t\tpanelHidden = true,\n\t\tonChange,\n\t\tclassName,\n\t\t...restProps\n\t} = props;\n\n\t// The Svelte source's `activeIndex` is `$bindable(0)`: a consumer can bind\n\t// it, or leave it alone and let the component keep writing its own copy.\n\t// React has no such channel, so the prop is controlled when it is passed\n\t// and this local copy takes over when it is not. Either way `onChange`\n\t// fires with the same value.\n\tconst [uncontrolledIndex, setUncontrolledIndex] = useState(0);\n\tconst isControlled = activeIndexProp !== undefined;\n\tconst activeIndex = isControlled ? activeIndexProp : uncontrolledIndex;\n\n\tconst reduced = useReducedMotion();\n\tconst effectiveCrossfade = crossfade && !reduced;\n\tconst panelDuration = effectiveCrossfade ? DURATIONS.base : 0;\n\n\t// Guards two edge cases at once: an empty `items` list (nothing to\n\t// index), and `activeIndex` drifting out of range if the caller shrinks\n\t// `items` out from under a value they own. `activeIndex` itself is never\n\t// silently rewritten here — only the INDEX USED FOR RENDERING the panel is\n\t// clamped, so a caller reading `activeIndex` back always sees exactly what\n\t// they (or the last intersection) set.\n\tconst panelIndex =\n\t\titems.length > 0 ? Math.min(Math.max(activeIndex, 0), items.length - 1) : 0;\n\n\t// Identity-stable, like the source's module-instance `setActive`: a row\n\t// that receives it as a prop is not re-rendered just because the parent\n\t// re-rendered, and the observer effect below does not re-observe.\n\tconst setActive = useEventCallback((i: number) => {\n\t\tif (activeIndex === i) return;\n\t\tif (!isControlled) setUncontrolledIndex(i);\n\t\tonChange?.(i, items[i] as T);\n\t});\n\n\t// ---- the `{#key panelIndex}` machinery -------------------------------\n\tconst [frames, setFrames] = useState<FrameEntry[]>(() => [\n\t\t{ key: 0, live: true, frozen: null, appear: false },\n\t]);\n\t/** The panel content as of the last COMMITTED render — what an exiting\n\t *  frame freezes, since by the time the change is detected `items` has\n\t *  already moved on. Written after the commit, never during render, so a\n\t *  render React starts and throws away leaves nothing behind. */\n\tconst lastPanelNode = useRef<ReactNode>(null);\n\tconst [prevPanelIndex, setPrevPanelIndex] = useState(panelIndex);\n\n\t// Detected during render (a render-phase state update, which React applies\n\t// before committing), not in an effect: the swap must land in the same\n\t// commit as the index change, exactly as the keyed block does. The\n\t// previous index is STATE, not a ref: React discards a thrown-away\n\t// render's state updates but not its ref writes, so a ref here would let\n\t// an abandoned render consume the change and silently drop the crossfade.\n\t// The new frame's key is derived inside the updater from the frames it\n\t// already holds, for the same reason.\n\tif (prevPanelIndex !== panelIndex) {\n\t\tsetPrevPanelIndex(panelIndex);\n\t\tconst frozen = lastPanelNode.current;\n\t\tsetFrames((current) => [\n\t\t\t...current.filter((f) => !f.live),\n\t\t\t...current.filter((f) => f.live).map((f) => ({ ...f, live: false, frozen })),\n\t\t\t{\n\t\t\t\tkey: current.reduce((highest, f) => Math.max(highest, f.key), 0) + 1,\n\t\t\t\tlive: true,\n\t\t\t\tfrozen: null,\n\t\t\t\tappear: true,\n\t\t\t},\n\t\t]);\n\t}\n\n\tconst liveNode = items.length > 0 ? panel(items[panelIndex] as T, panelIndex) : null;\n\n\t// Every commit, no dependency list: the snapshot an exiting frame freezes\n\t// is by definition \"whatever the panel rendered last time it landed\".\n\tuseIsomorphicLayoutEffect(() => {\n\t\tlastPanelNode.current = liveNode;\n\t});\n\n\treturn (\n\t\t// Exactly two children live directly under `.ft-stickyscroll`: the\n\t\t// items column and the panel. One wrapping flex line does BOTH layout\n\t\t// jobs — two side-by-side columns when the container fits both, or two\n\t\t// full-width rows when it doesn't — with no breakpoint to keep in\n\t\t// sync: each child asks for a 20rem basis and they wrap when the pair\n\t\t// (plus the real gap) no longer fits. That wrap is also what gives the\n\t\t// panel real sticky travel once stacked, since a flex item's\n\t\t// containing block is the whole container's content box (see the\n\t\t// stylesheet).\n\t\t<div\n\t\t\tref={forwardedRef}\n\t\t\tclassName={cn(\"ft-stickyscroll\", className)}\n\t\t\t{...restProps}\n\t\t\tdata-panel-side={panelSide}\n\t\t>\n\t\t\t<div className=\"ft-stickyscroll-items\">\n\t\t\t\t{items.map((it, i) => (\n\t\t\t\t\t<StickyScrollItem\n\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\tindex={i}\n\t\t\t\t\t\tactive={i === activeIndex}\n\t\t\t\t\t\tvalue={it}\n\t\t\t\t\t\titem={item}\n\t\t\t\t\t\tonActivate={setActive}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t</div>\n\n\t\t\t{items.length > 0 ? (\n\t\t\t\t<div\n\t\t\t\t\tclassName={cn(\"ft-stickyscroll-panel\", panelClass)}\n\t\t\t\t\taria-hidden={panelHidden ? \"true\" : undefined}\n\t\t\t\t>\n\t\t\t\t\t{frames.map((f) => (\n\t\t\t\t\t\t<PanelFrame\n\t\t\t\t\t\t\tkey={f.key}\n\t\t\t\t\t\t\topen={f.live}\n\t\t\t\t\t\t\tappear={f.appear}\n\t\t\t\t\t\t\tduration={panelDuration}\n\t\t\t\t\t\t\tonExited={() => setFrames((current) => current.filter((x) => x.key !== f.key))}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{f.live ? liveNode : f.frozen}\n\t\t\t\t\t\t</PanelFrame>\n\t\t\t\t\t))}\n\t\t\t\t</div>\n\t\t\t) : null}\n\t\t</div>\n\t);\n}\n\n/** Keeps the source's generic surface: a consumer imports the props as\n *  `StickyScrollProps<MyItem>`, and the component infers `T` from whatever\n *  `items` it's actually given. `forwardRef` erases a render function's type\n *  parameter, so the generic call signature is restored by assertion. */\ntype StickyScrollComponent = <T>(\n\tprops: StickyScrollProps<T> & RefAttributes<HTMLDivElement>\n) => ReactElement;\n\nconst StickyScrollForwarded = forwardRef(\n\tStickyScrollImpl as (\n\t\tprops: StickyScrollProps<unknown>,\n\t\tref: ForwardedRef<HTMLDivElement>\n\t) => ReactElement\n);\nStickyScrollForwarded.displayName = \"StickyScroll\";\n\nexport const StickyScroll = StickyScrollForwarded as unknown as StickyScrollComponent;\n","type":"registry:ui","target":"@ui/fancy/components/sticky-scroll/StickyScroll.tsx"},{"path":"react/src/components/sticky-scroll/index.ts","content":"\"use client\";\n\nexport { StickyScroll } from \"./StickyScroll\";\nexport type { StickyScrollProps } from \"./StickyScroll\";\n","type":"registry:ui","target":"@ui/fancy/components/sticky-scroll/index.ts"},{"path":"react/src/components/sticky-scroll/sticky-scroll.css","content":"/*\n * The two-column ⇄ stacked switch is FLEX WRAPPING, not a `@container`\n * query, and deliberately so: an element is styled by the query containers\n * ABOVE it, never by the `container-type` it declares on itself, so a\n * `@container` rule targeting `.ft-stickyscroll` could never switch this\n * element's own layout (it would match some unrelated ancestor container,\n * or nothing at all). Wrapping also does the arithmetic with the REAL gap,\n * so a `--ft-stickyscroll-gap` override moves the stacking point honestly.\n *\n * Wrapping is also what gives the panel real sticky travel once stacked: a\n * flex item's containing block is the whole flex container's content box,\n * so `top: 0` has the full column to move in. A collapsed single-column\n * GRID cannot do this — a grid item's containing block is its own grid\n * area, auto-sized to the panel's own content, so `position: sticky` would\n * compile and never visibly do anything.\n */\n.ft-stickyscroll {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: flex-start;\n\tgap: var(--ft-stickyscroll-gap, 2rem);\n\t/* Read only by the descendant-only query at the bottom of this sheet\n\t   (stacked sizing/placement), so those track container width rather\n\t   than viewport width. The layout switch itself is above, not there. */\n\tcontainer-type: inline-size;\n}\n\n/* `flex: 1 1 min(100%, 20rem)` on BOTH children is the whole responsive\n   rule: they share one line while `2 × (20rem + gap) − gap` fits and grow\n   to equal halves of it, and each takes its own full-width line when it\n   doesn't. `min(100%, …)` keeps a container narrower than 20rem from\n   overflowing instead of wrapping. */\n.ft-stickyscroll-items {\n\tdisplay: flex;\n\tflex-direction: column;\n\tflex: 1 1 min(100%, 20rem);\n\tgap: var(--ft-stickyscroll-gap, 2rem);\n\torder: 1;\n\tmin-width: 0;\n}\n\n.ft-stickyscroll-panel {\n\tposition: sticky;\n\ttop: var(--ft-stickyscroll-top, 10vh);\n\tmax-block-size: var(--ft-stickyscroll-panel-size, 80vh);\n\toverflow: hidden;\n\tdisplay: grid;\n\tflex: 1 1 min(100%, 20rem);\n\torder: 2;\n\tmin-width: 0;\n}\n\n/* Logical placement via `order`, not DOM reordering or explicit placement\n   — under `dir=\"rtl\"` the flex line's own inline flow already puts the\n   first item on the physical right, so swapping which item is logically\n   first is the whole trick; no `:dir(rtl)` override needed here. This\n   rule's higher specificity (two classes + one attribute selector) also\n   wins over the stacked-layout `order: -1` below regardless of which one\n   is later in source order, so `panelSide=\"start\"` still gets `order: 0`\n   once stacked — still first, since the items column stays `order: 1`. */\n.ft-stickyscroll[data-panel-side=\"start\"] .ft-stickyscroll-panel {\n\torder: 0;\n}\n\n/* Both frames stack exactly on top of each other during the crossfade —\n   the outgoing item's outro and the incoming item's intro genuinely\n   overlap in the DOM for a moment, and without this they would ALSO\n   overlap in layout, each shoving the other down the page. */\n.ft-stickyscroll-panel-frame {\n\tgrid-area: 1 / 1;\n}\n\n/*\n * Stacked-layout tuning ONLY. Every selector in here targets a DESCENDANT\n * of the query container, which is the only thing a container query can\n * ever style — the layout switch itself is flex wrapping, up at the top of\n * this sheet, for exactly that reason.\n *\n * The condition mirrors where that wrap actually happens for the DEFAULT\n * gap: the pair shares a line while `2 * (20rem + gap) - gap` fits, which\n * is `42rem` at `gap: 2rem` — not the \"two 20rem tracks\" 40rem a gap-blind\n * reading suggests. `width < 42rem` rather than `max-width: 42rem` so the\n * boundary agrees exactly with the wrap: at exactly 42rem the two columns\n * still fit side by side. A `--ft-stickyscroll-gap` override moves the\n * real wrap point but not this query (a container query condition can't\n * read a custom property), so the two can drift apart — see the README.\n *\n * `order: -1` (not DOM reordering) puts the panel on the first line so it\n * can overlay the items scrolling beneath it; the DOM keeps\n * items-before-panel, so the screen-reader/tab order is unchanged.\n */\n@container (width < 42rem) {\n\t.ft-stickyscroll-panel {\n\t\torder: -1;\n\t\ttop: 0;\n\t\tmax-block-size: var(--ft-stickyscroll-panel-size-stacked, 40svh);\n\t}\n\t/* Keeps a section that receives focus (Tab, or an in-page anchor) from\n\t   landing directly under the now-overlaying panel. */\n\t.ft-stickyscroll-item {\n\t\tscroll-margin-block-start: var(--ft-stickyscroll-panel-size-stacked, 40svh);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/sticky-scroll/sticky-scroll.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/sticky-scroll?framework=react"}}