{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-header",
  "type": "registry:component",
  "title": "Scroll Header",
  "description": "A page title with room around it that hands over to a compact sticky bar as the reader scrolls.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/panelui/scroll-header/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/scroll-header/index.tsx",
      "content": "'use client';\n\n/**\n * ScrollHeader — a page header that hands over to a compact sticky bar as the\n * reader scrolls.\n *\n * ```tsx\n * <ScrollHeader>\n *   <ScrollHeader.Cover />\n *   <ScrollHeader.Bar>\n *     <ScrollHeader.Title>Field notes</ScrollHeader.Title>\n *     <ScrollHeader.Actions>\n *       <Button variant=\"ghost\" size=\"icon\" aria-label=\"Share\">\n *         <ShareIcon />\n *       </Button>\n *     </ScrollHeader.Actions>\n *   </ScrollHeader.Bar>\n *   <ScrollHeader.Large>\n *     <ScrollHeader.Description>Issue 12 · November</ScrollHeader.Description>\n *     <ScrollHeader.Title>Field notes from the coast</ScrollHeader.Title>\n *   </ScrollHeader.Large>\n *   <article>{body}</article>\n * </ScrollHeader>\n * ```\n *\n * ## Two states, and the transit between them\n *\n * A page with a title has two states and every app draws both: the title\n * large, at rest, with room around it, and the title small, pinned, once the\n * reader is in the article. Hand-rolling the change is where pages stop\n * matching each other — one snaps, one crossfades at a different point, one\n * forgets the content underneath moved when the header did — so both states\n * and the transit between them are one component here.\n *\n * ## The band collapses; the article does not\n *\n * The header is a sticky band at the top of its scroller. The only thing the\n * scroll drives is the band's height: `bar + max(0, large - offset)`. The bar\n * is anchored to the band's top and the large block to its bottom, so the band\n * shrinking is what carries the block up behind the bar, and `overflow-hidden`\n * is what cuts it off there.\n *\n * The band is wrapped in a sticky box whose height stays at the expanded\n * header, which is the part that keeps the article still. Shrinking the box\n * itself would drag the article up at twice the speed of the scroll; leaving\n * the box alone means the content starts where it always started and simply\n * scrolls, while the visible band above it gets shorter. The article's first\n * line stays flush with the band's bottom edge until the block has gone.\n *\n * ## The two titles cross-fade; neither one morphs\n *\n * The large title and the bar title are separate elements that fade past each\n * other. A single title scaled and translated between the two positions tracks\n * beautifully until the text is long enough to truncate, at which point it is\n * animating between two different strings.\n *\n * Both are therefore in the tree at once, which is a problem for a screen\n * reader — one of them is invisible and would still be read. So the crossing\n * point is published to React as `collapsed`, and whichever title is not being\n * shown is hidden from accessibility. That is one render per crossing and no\n * more: everything that runs per frame is a CSS custom property on the root,\n * written from a `requestAnimationFrame` that reads the scroller once.\n *\n * ## Where the scroll comes from\n *\n * The window, by default. A page with its own scroller passes `scrollRef` an\n * element or a ref to one, and the header listens there instead. The listener\n * is passive, the work is batched into one frame, and no React state is set\n * until the progress reaches an end.\n *\n * ## The bar gains a surface when it takes over\n *\n * The bar's background, blur and hairline are one layer that fades in across\n * the middle of the collapse, so the bar closes behind the large block instead\n * of snapping shut. The bar and the large block cross-fade on ordered ranges:\n * the block is gone before the surface has finished closing, and the bar's\n * title arrives only after that, so nothing is ever legible on top of anything\n * else.\n *\n * ## What it needs\n *\n * To be the first thing in its scroll container, so offset zero is the\n * header's resting position. In a rounded container, the band takes the\n * container's radius at its top corners, so the cover and the blurred bar\n * follow the corner rather than painting square past it. It has no fixed height of its own: the large\n * block is measured, and whatever is put in it — a title, a byline, a row of\n * chips — sets the distance the header collapses over.\n */\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactNode,\n  type Ref,\n} from 'react';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils';\nimport {\n  BAR_TITLE_ARRIVE,\n  LARGE_EXIT,\n  SURFACE_ARRIVE,\n  bandHeight,\n  collapseProgress,\n  contentShift,\n  isCrossing,\n  snapTarget,\n  stretchOf,\n  type FadeStep,\n} from './scroll-header-math';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\n/**\n * Height of the pinned bar, in pixels, unless `barHeight` says otherwise. On\n * the web this is the comfortable size for a row of small controls, and the\n * article starts right under it once the large block has gone.\n */\nconst BAR_HEIGHT = 56;\n\n/** How long a bar with no large block takes to close, when there is no distance to interpolate over. */\nconst BAR_ONLY_HANDOVER = 200;\n\n/** The collection's curve, so a hand-over here moves like one anywhere else. */\nconst EASE = 'ease-[cubic-bezier(0.3,0.7,0.1,1)]';\n\n/** The ordered fade ranges, written once as CSS so the browser does the interpolating. */\nconst LARGE_OPACITY = fadeOut(LARGE_EXIT);\nconst SURFACE_OPACITY = fadeIn(SURFACE_ARRIVE);\nconst BAR_TITLE_OPACITY = fadeIn(BAR_TITLE_ARRIVE);\n\n/** One ramp travelling from hidden to shown across a step of the collapse. */\nfunction fadeIn([from, to]: FadeStep): string {\n  return `clamp(0, calc((var(--scroll-header-fade, 0) - ${from}) / ${to - from}), 1)`;\n}\n\n/** One ramp travelling from shown to hidden across a step of the collapse. */\nfunction fadeOut([from, to]: FadeStep): string {\n  return `clamp(0, calc((${to} - var(--scroll-header-fade, 0)) / ${to - from}), 1)`;\n}\n\n/**\n * A layout effect that is safe to run while the component is rendered on the\n * server, where there is no layout to read yet.\n */\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;\n\n/**\n * Sets `inert` as an attribute rather than as a prop.\n *\n * React 18 drops a boolean `inert`; React 19 understands it. Writing the\n * attribute on the node is the one behaviour both versions share, and it is\n * the same pattern the nav rail uses.\n */\nfunction useInert<T extends HTMLElement>(inert: boolean) {\n  const ref = useRef<T>(null);\n  useIsomorphicLayoutEffect(() => {\n    ref.current?.toggleAttribute('inert', inert);\n  }, [inert]);\n  return ref;\n}\n\nconst scrollHeaderVariants = tv({\n  slots: {\n    // The scroller's radius is handed down to the band, because a blurred\n    // bar's backdrop is not clipped by an ancestor's rounded corner, only by\n    // its own: without it the cover shows square past a rounded frame.\n    root: 'relative w-full rounded-[inherit]',\n    // The box that keeps its height while the band inside it shrinks.\n    sticky: 'pointer-events-none sticky top-0 z-30 w-full rounded-t-[inherit]',\n    band: 'pointer-events-auto relative w-full overflow-hidden rounded-t-[inherit]',\n    spacer: 'w-full',\n    bar: 'absolute inset-x-0 top-0 z-20 flex items-center gap-3 rounded-t-[inherit] px-6',\n    surface: 'pointer-events-none absolute inset-0 rounded-t-[inherit] border-b',\n    large: 'relative z-10 flex flex-col gap-1.5 px-6 pb-6',\n    cover: 'pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-t-[inherit]',\n    actions: 'relative ml-auto flex shrink-0 items-center gap-2',\n    titleLarge: 'block text-3xl font-bold tracking-tight text-balance text-foreground sm:text-4xl',\n    titleBar: 'relative block min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground',\n    description: 'relative text-sm text-muted-foreground',\n  },\n  variants: {\n    /** What the bar is drawn on once it has taken over. */\n    surface: {\n      // A real material: the content passing under the bar stays legible as\n      // shape and colour while losing the detail that would compete with the\n      // title on top. The fill behind it is what keeps the title readable.\n      blur: { surface: 'border-border bg-background/75 backdrop-blur-xl' },\n      plain: { surface: 'border-border bg-background' },\n      none: { surface: 'border-transparent bg-transparent' },\n    },\n    divider: {\n      true: {},\n      false: { surface: 'border-transparent' },\n    },\n  },\n  defaultVariants: { surface: 'blur', divider: true },\n});\n\n/** What the bar is drawn on once the large block has gone. */\nexport type ScrollHeaderSurface = 'blur' | 'plain' | 'none';\n\n/** Which half of the header a title is standing in. */\ntype ScrollHeaderSlot = 'bar' | 'large';\n\n/** An element to read scroll from, or a ref that will hold one. */\nexport type ScrollHeaderScrollSource =\n  | HTMLElement\n  | { readonly current: HTMLElement | null };\n\ninterface ScrollHeaderContextValue {\n  /** Whether the bar has taken over, published once per crossing. */\n  collapsed: boolean;\n  /** Whether a large block was written as a part, whether measured or not. */\n  hasLarge: boolean;\n  /** Whether a bar was written as a part. */\n  hasBar: boolean;\n  /** Height the bar keeps, with no large block's height in it. */\n  barBand: number;\n  /** Measure or release the large block. */\n  setLargeNode: (node: HTMLDivElement | null) => void;\n}\n\nconst ScrollHeaderContext = createContext<ScrollHeaderContextValue | undefined>(undefined);\n\nfunction useScrollHeader(component: string): ScrollHeaderContextValue {\n  const context = useContext(ScrollHeaderContext);\n  if (!context) throw new Error(`${component} must be used within a <ScrollHeader>`);\n  return context;\n}\n\n/**\n * Which half a part is in. `Title` reads it to know which size to be and which\n * way to fade, so the same element can be written in both places.\n */\nconst ScrollHeaderSlotContext = createContext<ScrollHeaderSlot>('large');\n\nexport interface ScrollHeaderProps extends ComponentProps<'div'> {\n  /**\n   * Height of the pinned bar in pixels. The large block and the article are\n   * laid out around it, so changing it moves the resting state rather than\n   * cropping the bar. Default `56`.\n   */\n  barHeight?: number;\n  /**\n   * How much of the large block has to leave before the bar has fully taken\n   * over, as a fraction of its height. Below 1 the crossing happens early,\n   * which suits a tall block whose last few pixels are not worth waiting for.\n   * Default `1`.\n   */\n  threshold?: number;\n  /**\n   * Settle a part-scrolled band open or closed once the scroller comes to\n   * rest, rather than leaving the header half-collapsed. It moves the page, so\n   * it is off by default; turn it on for a header that owns the scroll.\n   * Default `false`.\n   */\n  snap?: boolean;\n  /**\n   * Let the band grow past its resting height when the scroller is pulled\n   * down. A cover fills the band, so this is what stretches it. Only a\n   * platform that reports a negative scroll offset can do it — Safari and\n   * other rubber-banding scrollers — and it is off under reduced motion.\n   * Default `true`.\n   */\n  stretch?: boolean;\n  /**\n   * The element to read scroll from: the window by default, or an element or a\n   * ref to one for a page with its own scroller. The header must be the first\n   * thing in that scroller, so offset zero is its resting position.\n   */\n  scrollRef?: ScrollHeaderScrollSource;\n  /**\n   * Called as the bar takes over, and again when the large block comes back.\n   * Fires on the crossing, not on every frame, and not for a page that merely\n   * loaded scrolled.\n   */\n  onCollapsedChange?: (collapsed: boolean) => void;\n  /** The parts, then the page content. Exactly one `Bar` and one `Large` are expected. */\n  children?: ReactNode;\n}\n\n/**\n * The band, and the content that scrolls under it. Everything that is not a\n * part is treated as the page's content.\n */\nfunction ScrollHeaderRoot({\n  className,\n  barHeight = BAR_HEIGHT,\n  threshold = 1,\n  snap = false,\n  stretch = true,\n  scrollRef,\n  onCollapsedChange,\n  children,\n  style,\n  ref,\n  ...props\n}: ScrollHeaderProps) {\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const largeRef = useRef<HTMLDivElement | null>(null);\n  const [collapsed, setCollapsed] = useState(false);\n  const reducedMotion = usePrefersReducedMotion();\n\n  const { parts, content } = useMemo(() => splitChildren(children), [children]);\n  const hasLarge = parts.large !== null;\n  const hasBar = parts.bar !== null;\n  const barBand = hasBar ? barHeight : 0;\n\n  // Everything the frame loop needs is read through refs, so the loop and its\n  // listener are created once rather than rebuilt whenever a prop changes.\n  const state = useRef({ offset: 0, large: 0, collapsed: false });\n  // The first read after mount positions the bar; it is not a crossing, and a\n  // page that arrived scrolled should not be told that it just scrolled.\n  const mounted = useRef(false);\n  const config = useRef({\n    barBand,\n    threshold,\n    snap,\n    stretch,\n    hasBar,\n    reducedMotion,\n    scrollSource: scrollRef,\n    onCollapsedChange,\n  });\n  config.current = {\n    barBand,\n    threshold,\n    snap,\n    stretch,\n    hasBar,\n    reducedMotion,\n    scrollSource: scrollRef,\n    onCollapsedChange,\n  };\n\n  /**\n   * The one write the scroll makes: the collapse's numbers as custom\n   * properties, and React state only where the progress reached an end.\n   */\n  const apply = useCallback((offset: number) => {\n    const root = rootRef.current;\n    const large = state.current.large;\n    const { threshold, stretch, hasBar, reducedMotion, onCollapsedChange } = config.current;\n\n    const stretchPx = stretchOf(offset, stretch && !reducedMotion);\n    const progress = hasBar ? collapseProgress(offset, large, threshold) : 0;\n    // Under reduced motion the bar still condenses; it is the fades that stop\n    // travelling. They change once, at the crossing, and a short transition\n    // spends them there rather than across the whole scroll.\n    const fade = reducedMotion ? (progress >= 1 ? 1 : 0) : progress;\n    const band = bandHeight(config.current.barBand, large, offset, stretchPx);\n    const shift = contentShift(offset, large, stretchPx);\n\n    if (root) {\n      root.style.setProperty('--scroll-header-fade', String(fade));\n      root.style.setProperty('--scroll-header-band', `${band}px`);\n      root.style.setProperty('--scroll-header-shift', `${shift}px`);\n      root.style.setProperty('--scroll-header-rest', `${config.current.barBand + large}px`);\n    }\n\n    state.current.offset = offset;\n    const next = progress >= 1;\n    if (!mounted.current) {\n      // A page can arrive already scrolled. Put the bar in its right state\n      // without reporting it as something that just happened.\n      state.current.collapsed = next;\n      setCollapsed(next);\n    } else if (isCrossing(next, state.current.collapsed)) {\n      state.current.collapsed = next;\n      setCollapsed(next);\n      onCollapsedChange?.(next);\n    }\n  }, []);\n\n  /** Take a fresh height for the large block and re-apply the current offset. */\n  const measure = useCallback(() => {\n    const next = largeRef.current?.offsetHeight ?? 0;\n    if (Math.abs(next - state.current.large) >= 0.5) state.current.large = next;\n    apply(state.current.offset);\n  }, [apply]);\n\n  const setLargeNode = useCallback(\n    (node: HTMLDivElement | null) => {\n      // Only a node is measured here. React detaches the old ref before it\n      // attaches the new one, and measuring the gap would read a header with\n      // no large block for a frame.\n      largeRef.current = node;\n      if (node) measure();\n    },\n    [measure]\n  );\n\n  // Measure and sync before the first paint this component is part of. The\n  // server renders the expanded state, and so does the first client frame:\n  // the measurement is of content that is already laid out.\n  useIsomorphicLayoutEffect(() => {\n    measure();\n    const scrollSource = config.current.scrollSource;\n    const resolved = resolveSource(scrollSource);\n    // A ref to a parent scroller is not attached yet, so do not read the\n    // window in its place. The passive effect reads it once it exists.\n    apply(resolved || !scrollSource ? offsetOf(resolved) : 0);\n  }, [apply, measure, hasLarge, barHeight, threshold, snap, stretch, reducedMotion, scrollRef]);\n\n  // Content changes and font loading both change the distance the header\n  // collapses over. The observer is the only thing that notices either.\n  useIsomorphicLayoutEffect(() => {\n    const node = largeRef.current;\n    if (!node || typeof ResizeObserver === 'undefined') return;\n    const observer = new ResizeObserver(() => measure());\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [measure, hasLarge]);\n\n  useEffect(() => {\n    const source = resolveSource(config.current.scrollSource);\n    const target: HTMLElement | Window = source ?? window;\n    let frame = 0;\n    let idle: ReturnType<typeof setTimeout> | undefined;\n\n    const read = () => {\n      frame = 0;\n      apply(offsetOf(resolveSource(config.current.scrollSource)));\n      if (config.current.snap) {\n        clearTimeout(idle);\n        idle = setTimeout(settle, 160);\n      }\n    };\n\n    const onScroll = () => {\n      if (frame) return;\n      frame = requestAnimationFrame(read);\n    };\n\n    /**\n     * Finish a part-scrolled hand-over once the scroller is still. The target\n     * never goes past what the scroller can actually reach, so a short page\n     * settles at its end rather than asking for a scroll it cannot make.\n     */\n    const settle = () => {\n      const current = resolveSource(config.current.scrollSource);\n      const offset = offsetOf(current);\n      const next = snapTarget(offset, state.current.large);\n      if (next === null) return;\n      const limit = current\n        ? current.scrollHeight - current.clientHeight\n        : document.documentElement.scrollHeight - window.innerHeight;\n      const top = Math.min(next, Math.max(limit, 0));\n      if (Math.abs(top - offset) <= 1) return;\n      const behavior: ScrollBehavior = config.current.reducedMotion ? 'auto' : 'smooth';\n      if (current) current.scrollTo({ top, behavior });\n      else window.scrollTo({ top, behavior });\n    };\n\n    // A page restored mid-scroll has a header that is already collapsed.\n    read();\n    // Only now is the first position settled; anything after this is a real\n    // crossing.\n    mounted.current = true;\n    target.addEventListener('scroll', onScroll, { passive: true });\n    return () => {\n      target.removeEventListener('scroll', onScroll);\n      if (frame) cancelAnimationFrame(frame);\n      clearTimeout(idle);\n    };\n  }, [apply, scrollRef]);\n\n  const context = useMemo<ScrollHeaderContextValue>(\n    () => ({ collapsed, hasLarge, hasBar, barBand, setLargeNode }),\n    [collapsed, hasLarge, hasBar, barBand, setLargeNode]\n  );\n\n  const { root, sticky, band, spacer } = scrollHeaderVariants();\n\n  return (\n    <ScrollHeaderContext.Provider value={context}>\n      <div\n        {...props}\n        ref={composeRefs(rootRef, ref)}\n        data-slot=\"scroll-header\"\n        data-collapsed={collapsed ? '' : undefined}\n        className={root({ className })}\n        style={style}\n      >\n        <div\n          className={sticky()}\n          data-slot=\"scroll-header-sticky\"\n          style={{\n            // A global nav above the page can be cleared from CSS without a prop.\n            top: 'var(--scroll-header-top, 0px)',\n            height: 'var(--scroll-header-rest, auto)',\n          }}\n        >\n          <div\n            className={band()}\n            data-slot=\"scroll-header-band\"\n            style={{ height: 'var(--scroll-header-band, auto)' }}\n          >\n            {parts.cover}\n            <div\n              aria-hidden=\"true\"\n              className={spacer()}\n              data-slot=\"scroll-header-spacer\"\n              style={{ height: barBand }}\n            />\n            {parts.large}\n            {parts.bar}\n          </div>\n        </div>\n        {content}\n      </div>\n    </ScrollHeaderContext.Provider>\n  );\n}\nScrollHeaderRoot.displayName = 'ScrollHeader';\n\nexport interface ScrollHeaderBarProps extends ComponentProps<'div'> {\n  /**\n   * What the bar is drawn on once it has taken over. `blur` frosts it, so the\n   * content passing under the bar stays legible as shape and colour. `plain`\n   * is the background token, and `none` leaves it clear for a bar over a cover\n   * that should stay visible. Default `blur`.\n   */\n  surface?: ScrollHeaderSurface;\n  /** A hairline under the bar, drawn with its surface. Default `true`. */\n  divider?: boolean;\n}\n\n/**\n * The pinned bar. Its contents never move; its surface fades in as the large\n * block leaves, which is what makes the change read as one crossfade rather\n * than two things happening at once.\n */\nfunction ScrollHeaderBar({\n  className,\n  surface = 'blur',\n  divider = true,\n  children,\n  style,\n  render,\n  ...props\n}: ScrollHeaderBarProps & RenderProp<'div'>) {\n  const { barBand, hasLarge } = useScrollHeader('ScrollHeader.Bar');\n  const slots = scrollHeaderVariants({ surface, divider });\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'scroll-header-bar',\n        'data-surface': surface,\n        className: slots.bar({ className }),\n        style: { height: barBand, ...style },\n        children: (\n          <>\n            {/*\n              The surface is one layer rather than a class toggled at the\n              crossing: the opacity travels with the scroll, and the divider is\n              inside it, so the hairline cannot arrive on a different curve.\n            */}\n            <div\n              aria-hidden=\"true\"\n              data-slot=\"scroll-header-surface\"\n              className={cn(\n                slots.surface(),\n                'motion-reduce:transition-opacity motion-reduce:duration-200',\n                EASE,\n                !hasLarge && 'transition-opacity'\n              )}\n              style={{\n                opacity: SURFACE_OPACITY,\n                transitionDuration: hasLarge ? undefined : `${BAR_ONLY_HANDOVER}ms`,\n              }}\n            />\n            <ScrollHeaderSlotContext.Provider value=\"bar\">\n              {children}\n            </ScrollHeaderSlotContext.Provider>\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nScrollHeaderBar.displayName = 'ScrollHeader.Bar';\n\nexport interface ScrollHeaderLargeProps extends ComponentProps<'div'> {}\n\n/**\n * The expanded block. Its measured height is the distance the header\n * collapses over, so whatever is put in it — a title, a byline, a row of\n * chips — sets the scroll distance rather than a prop having to agree with\n * it.\n */\nfunction ScrollHeaderLarge({\n  className,\n  style,\n  children,\n  render,\n  ref,\n  ...props\n}: ScrollHeaderLargeProps & RenderProp<'div'>) {\n  const { collapsed, hasBar, setLargeNode } = useScrollHeader('ScrollHeader.Large');\n  const slots = scrollHeaderVariants();\n  const hidden = hasBar && collapsed;\n  const inertRef = useInert<HTMLDivElement>(hidden);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'scroll-header-large',\n        // The block is carried up by the same amount the band shrank, which\n        // keeps its bottom edge on the band's.\n        ref: composeRefs(setLargeNode, ref, inertRef),\n        className: cn(\n          slots.large({ className }),\n          'motion-reduce:transition-opacity motion-reduce:duration-200',\n          EASE\n        ),\n        style: {\n          transform: 'translateY(var(--scroll-header-shift, 0px))',\n          opacity: LARGE_OPACITY,\n          pointerEvents: hidden ? 'none' : undefined,\n          ...style,\n        },\n        // `inert` is the whole answer: it takes the block out of the tab order\n        // and out of the accessibility tree together, without a second state\n        // to keep in step with this one. It is written to the node by\n        // `inertRef`, so it works in React 18 and 19 alike.\n        'aria-hidden': hidden || undefined,\n        children: (\n          <ScrollHeaderSlotContext.Provider value=\"large\">{children}</ScrollHeaderSlotContext.Provider>\n        ),\n      },\n      props\n    ),\n  });\n}\nScrollHeaderLarge.displayName = 'ScrollHeader.Large';\n\nexport interface ScrollHeaderTitleProps extends ComponentProps<'span'> {}\n\n/**\n * The page's title. Written in both halves and styled from whichever it is in:\n * large and at rest in the block, compact and fading in on the bar.\n *\n * It is a `span` with a heading role so the same part works in both places;\n * `render={<h1 />}` gives a page a real heading element where one is wanted.\n */\nfunction ScrollHeaderTitle({\n  className,\n  style,\n  render,\n  ...props\n}: ScrollHeaderTitleProps & RenderProp<'span'>) {\n  const slot = useContext(ScrollHeaderSlotContext);\n  const { collapsed, hasLarge } = useScrollHeader('ScrollHeader.Title');\n  const slots = scrollHeaderVariants();\n  // The large title is the one being read until the bar has taken over. Both\n  // are in the tree the whole time, and only one of them should be — unless\n  // there is no large title, in which case the bar's is always read.\n  const hidden = slot === 'bar' && hasLarge && !collapsed;\n\n  return useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'scroll-header-title',\n        role: 'heading',\n        'aria-level': 1,\n        'aria-hidden': hidden || undefined,\n        className: cn(\n          slot === 'bar' ? slots.titleBar({ className }) : slots.titleLarge({ className }),\n          'motion-reduce:transition-opacity motion-reduce:duration-200',\n          EASE\n        ),\n        style: {\n          opacity: slot === 'bar' ? (hasLarge ? BAR_TITLE_OPACITY : 1) : undefined,\n          ...style,\n        },\n      },\n      props\n    ),\n  });\n}\nScrollHeaderTitle.displayName = 'ScrollHeader.Title';\n\nexport interface ScrollHeaderDescriptionProps extends ComponentProps<'p'> {}\n\n/** The quiet line under the title — a count, a byline, a date. */\nfunction ScrollHeaderDescription({\n  className,\n  render,\n  ...props\n}: ScrollHeaderDescriptionProps & RenderProp<'p'>) {\n  return useRender({\n    defaultTagName: 'p',\n    render,\n    props: mergeProps(\n      { 'data-slot': 'scroll-header-description', className: scrollHeaderVariants().description({ className }) },\n      props\n    ),\n  });\n}\nScrollHeaderDescription.displayName = 'ScrollHeader.Description';\n\nexport interface ScrollHeaderActionsProps extends ComponentProps<'div'> {}\n\n/**\n * The controls at the trailing end of the bar. They stay put and stay\n * reachable — only the bar's surface and title are part of the transition.\n */\nfunction ScrollHeaderActions({\n  className,\n  render,\n  ...props\n}: ScrollHeaderActionsProps & RenderProp<'div'>) {\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      { 'data-slot': 'scroll-header-actions', className: scrollHeaderVariants().actions({ className }) },\n      props\n    ),\n  });\n}\nScrollHeaderActions.displayName = 'ScrollHeader.Actions';\n\nexport interface ScrollHeaderCoverProps extends ComponentProps<'div'> {\n  /** A picture behind the header, laid out to fill the band rather than cropped by the document. */\n  src?: string;\n  /** Alt text for `src`. Empty by default: the cover is decoration behind the title. */\n  alt?: string;\n  /** A wash over the cover, so the title stays legible on a bright picture. Default `true`. */\n  scrim?: boolean;\n}\n\n/**\n * A picture or a gradient filling the band. It has no height of its own — the\n * band's height is its height, which is why it stretches on an over-scroll by\n * being laid out larger rather than by being scaled up and going soft.\n *\n * With no `src` it draws a gradient from the theme's chart tokens, so an app\n * that puts its charts on brand puts this on brand with them.\n */\nfunction ScrollHeaderCover({\n  className,\n  src,\n  alt = '',\n  scrim = true,\n  children,\n  style,\n  render,\n  ...props\n}: ScrollHeaderCoverProps & RenderProp<'div'>) {\n  const slots = scrollHeaderVariants();\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'scroll-header-cover',\n        'aria-hidden': alt ? undefined : true,\n        className: slots.cover({ className }),\n        style,\n        children: (\n          <>\n            {src ? (\n              <img alt={alt} className=\"absolute inset-0 size-full object-cover\" src={src} />\n            ) : (\n              <div\n                aria-hidden=\"true\"\n                className=\"absolute inset-0\"\n                style={{\n                  backgroundImage:\n                    'linear-gradient(135deg, var(--chart-1, var(--primary)), var(--chart-2, var(--primary)))',\n                }}\n              />\n            )}\n            {scrim ? (\n              <div\n                aria-hidden=\"true\"\n                className=\"absolute inset-0 bg-gradient-to-t from-background/90 via-background/30 to-transparent\"\n              />\n            ) : null}\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nScrollHeaderCover.displayName = 'ScrollHeader.Cover';\n\n/**\n * Sort the children into the three band slots and the page's content. Sorting\n * by component rather than by order is what lets a page read top-down — bar,\n * block, article — while the band still draws the cover behind both.\n */\nfunction splitChildren(children: ReactNode): {\n  parts: { cover: ReactNode; large: ReactNode; bar: ReactNode };\n  content: ReactNode;\n} {\n  let cover: ReactNode = null;\n  let large: ReactNode = null;\n  let bar: ReactNode = null;\n  const content: ReactNode[] = [];\n\n  for (const child of Children.toArray(children)) {\n    if (!isValidElement(child)) {\n      content.push(child);\n      continue;\n    }\n    if (child.type === ScrollHeaderCover) cover = child;\n    else if (child.type === ScrollHeaderLarge) large = child;\n    else if (child.type === ScrollHeaderBar) bar = child;\n    else content.push(child);\n  }\n\n  return { parts: { cover, large, bar }, content };\n}\n\n/** The element a `scrollRef` names, or null for the window. */\nfunction resolveSource(source: ScrollHeaderScrollSource | undefined): HTMLElement | null {\n  if (!source) return null;\n  return 'current' in source ? source.current : source;\n}\n\n/** How far the page or the given element has scrolled. */\nfunction offsetOf(source: HTMLElement | null): number {\n  if (source) return source.scrollTop;\n  if (typeof window === 'undefined') return 0;\n  return window.scrollY;\n}\n\n/** Point several refs at one node, skipping the ones that were not given. */\nfunction composeRefs<T>(...refs: (Ref<T> | undefined)[]): (node: T | null) => void {\n  return (node) => {\n    for (const ref of refs) {\n      if (typeof ref === 'function') ref(node);\n      else if (ref) ref.current = node;\n    }\n  };\n}\n\n/**\n * Whether the reader asked for less motion.\n *\n * Read once on mount and kept current, rather than asked per frame. The\n * server has no answer, so it renders the answer that needs no animation and\n * the first client render agrees with it.\n */\nfunction usePrefersReducedMotion(): boolean {\n  const [reduced, setReduced] = useState(false);\n\n  useEffect(() => {\n    const query = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const sync = () => setReduced(query.matches);\n    sync();\n    query.addEventListener('change', sync);\n    return () => query.removeEventListener('change', sync);\n  }, []);\n\n  return reduced;\n}\n\nexport const ScrollHeader = Object.assign(ScrollHeaderRoot, {\n  Bar: ScrollHeaderBar,\n  Large: ScrollHeaderLarge,\n  Title: ScrollHeaderTitle,\n  Description: ScrollHeaderDescription,\n  Actions: ScrollHeaderActions,\n  Cover: ScrollHeaderCover,\n});\n\nexport {\n  ScrollHeaderBar,\n  ScrollHeaderLarge,\n  ScrollHeaderTitle,\n  ScrollHeaderDescription,\n  ScrollHeaderActions,\n  ScrollHeaderCover,\n};\n"
    },
    {
      "path": "components/panelui/scroll-header/scroll-header-math.ts",
      "type": "registry:component",
      "target": "@components/panelui/scroll-header/scroll-header-math.ts",
      "content": "/**\n * The numbers a collapsing header is made of, kept out of the views that draw\n * them so each one can be checked on its own.\n *\n * Every function here is pure: it takes the scroll offset and the measured\n * height of the large block and returns a value. Nothing reads the DOM and\n * nothing subscribes to anything, which is what lets the component read these\n * once per animation frame and write the answer to a CSS custom property\n * instead of asking React to render.\n */\n\n/** How close to an end a band has to be before it is treated as settled. */\nexport const SNAP_EPSILON = 1;\n\n/** A large block shorter than this is treated as no large block at all. */\nexport const MINIMUM_SPAN = 1;\n\n/**\n * How far through the collapse each fade runs.\n *\n * The hand-over is ordered rather than one crossfade, because a single linear\n * fade puts both titles on the screen at once and the large block travels to\n * exactly the pixels the bar's title occupies. The block is gone before it\n * reaches the bar, the surface closes behind it, and only then does the bar's\n * own title arrive.\n */\nexport const LARGE_EXIT: readonly [number, number] = [0, 0.45];\nexport const SURFACE_ARRIVE: readonly [number, number] = [0.35, 0.75];\nexport const BAR_TITLE_ARRIVE: readonly [number, number] = [0.6, 1];\n\n/** A part of the collapse, as a start and an end between 0 and 1. */\nexport type FadeStep = readonly [number, number];\n\n/** Somewhere between two ends, without ever running past either. */\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\n/** Whether a header has a large block to cross at all. */\nexport function hasSpan(largeHeight: number): boolean {\n  return largeHeight >= MINIMUM_SPAN;\n}\n\n/**\n * How far through the change from large block to bar the header is, 0 to 1.\n *\n * `threshold` shortens the distance the crossfade runs over without changing\n * how far the block actually travels, so a tall block can finish handing over\n * before its last few points have left. It is floored rather than allowed to\n * reach zero: a crossfade with no distance is a jump cut.\n *\n * With no large block there is no distance to interpolate over, so this is the\n * bar's only cue that the content has moved at all, and it answers on the\n * first point of scroll rather than waiting for a span that will never come.\n */\nexport function collapseProgress(offset: number, largeHeight: number, threshold: number): number {\n  const span = largeHeight * clamp(threshold, 0.05, 1);\n  if (span < MINIMUM_SPAN) return offset > 0 ? 1 : 0;\n  return clamp(offset / span, 0, 1);\n}\n\n/**\n * How much of the scroll offset actually moves the large block.\n *\n * The block can only be carried up as far as its own height; past that the\n * band is already at the bar and the content simply scrolls under it. A\n * negative offset — an over-scroll on a platform that reports one — is not\n * travel: it becomes stretch, which is handled separately.\n */\nexport function clampOffset(offset: number, largeHeight: number): number {\n  return clamp(offset, 0, Math.max(largeHeight, 0));\n}\n\n/**\n * How far the scroller is pulled past its start, in pixels.\n *\n * Only Safari and other rubber-banding scrollers report a negative offset; a\n * platform that clamps at zero reports nothing and gets no stretch. `stretch`\n * off is the same as a platform that never reports one.\n */\nexport function stretchOf(offset: number, stretch: boolean): number {\n  if (!stretch) return 0;\n  return Math.max(-offset, 0);\n}\n\n/**\n * The height of the band at a given scroll position.\n *\n * The bar's share is fixed and the large block's is whatever it has left, so\n * the band and the content underneath can never disagree about where the\n * header ends. An over-scroll makes the remainder larger than the block ever\n * was, which is what stretches a cover: it is laid out taller rather than\n * scaled up, so a photograph in it does not go soft.\n */\nexport function bandHeight(\n  barBand: number,\n  largeHeight: number,\n  offset: number,\n  stretchPx = 0\n): number {\n  const span = Math.max(largeHeight, 0);\n  const remaining = span - clampOffset(offset, span);\n  return barBand + Math.max(remaining, 0) + Math.max(stretchPx, 0);\n}\n\n/**\n * The translate that keeps the large block's bottom on the band's.\n *\n * The block is in normal flow below the bar, so moving it by the same amount\n * the band shrank — downwards, hence negative for a positive offset — is what\n * keeps the two flush. Under an over-scroll the band grows and the block is\n * pushed the other way, which is what a cover filling the band needs.\n */\nexport function contentShift(offset: number, largeHeight: number, stretchPx = 0): number {\n  return Math.max(stretchPx, 0) - clampOffset(offset, largeHeight);\n}\n\n/**\n * Where a settled scroller should be left, or `null` to leave it where it is.\n *\n * Only a band caught part-way is moved: a band already at one end has nothing\n * to settle. Past the halfway point it closes, before it opens, so the shorter\n * journey always wins.\n */\nexport function snapTarget(offset: number, largeHeight: number): number | null {\n  if (largeHeight < MINIMUM_SPAN) return null;\n  if (offset <= SNAP_EPSILON || offset >= largeHeight - SNAP_EPSILON) return null;\n  return offset >= largeHeight / 2 ? largeHeight : 0;\n}\n\n/**\n * Whether a new collapsed state is a crossing worth reporting.\n *\n * The first read after mount is not a crossing: reporting it would hand\n * `onCollapsedChange` a `true` for a page that merely loaded scrolled. After\n * that, only a change counts.\n */\nexport function isCrossing(next: boolean, previous: boolean | null): boolean {\n  return previous !== null && next !== previous;\n}\n\n/**\n * How far a value has travelled through a step, 0 to 1.\n *\n * A zero-width step is a jump cut: below its end nothing has happened, at or\n * past it the whole change has.\n */\nexport function ramp(value: number, step: FadeStep): number {\n  const [from, to] = step;\n  if (to <= from) return value >= to ? 1 : 0;\n  return clamp((value - from) / (to - from), 0, 1);\n}\n"
    }
  ]
}
