{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "terms-scroll-gate",
  "title": "Terms Scroll Gate",
  "description": "A document that has to be scrolled to its end before the control under it will do anything — the \"scroll to accept\" box on a terms page, and the one piece of a consent flow that is almost always hand-rolled and almost always broken. Reach for it wherever somebody has to be shown a document before they can continue: terms of service and an end-user licence, a privacy policy, cookie and data-processing consent, a SaaS onboarding step, financial and insurance disclosures, a mortgage or loan agreement, a medical or clinical consent form, a research study participation sheet, age verification, an employee handbook or code-of-conduct acknowledgement, an NDA, a liability waiver, a safety briefing, a tenancy agreement, a terms-change re-consent prompt, and release notes or a changelog nobody is allowed to have missed. Common asks it answers: \"scroll to accept\", \"scroll to bottom to enable button\", \"disable agree button until scrolled to bottom\", \"must scroll terms before accepting\", \"read to the end before continuing\", \"terms and conditions scroll react\", \"shadcn terms scroll\", \"consent gate react\", \"scroll to agree checkbox\", \"scrolled to bottom detection react\", \"detect scroll end react\", \"scroll to bottom not working\", \"scrollTop clientHeight scrollHeight never equal\", \"scroll end detection off by one pixel\", \"scrolled to bottom fails at browser zoom\", \"agree button never enables\", \"short terms no scrollbar button stays disabled\", \"terms modal scroll to bottom\", \"EULA scroll component\", \"privacy policy accept component\", \"useOnScreen hook\", \"has element been seen react\", \"IntersectionObserver seen once\", \"useInView latch\", \"規約 最後までスクロール 同意\", \"同意ボタン スクロール 有効化\", \"スクロール 最下部 判定 react\", \"利用規約 同意 コンポーネント\". **The arithmetic everybody writes for this is the wrong tool, and its failure is the one that matters.** `scrollTop + clientHeight >= scrollHeight` is the obvious test and it is three separate bugs. `scrollTop` is fractional while the other two are rounded integers, so at any browser zoom other than 100% the sum lands a fraction of a pixel short of the total and the agree button never enables — on a page where there is nothing at all the reader can do about it. A document shorter than its box never scrolls, so the same test has to be special-cased or a four-line policy locks the form forever. And a box that has not been laid out — in a dialog that has not opened, a tab that is not selected, anything `display: none` — reports zeroes for all three, which *satisfies* the test: the gate opens for terms that have never been on screen. So the end of the document is marked with an element and watched with an IntersectionObserver instead, and all three dissolve rather than being fixed. Intersection is computed in layout units with no rounding, a short document's end marker is simply already visible, and an element in an unrendered subtree has no boxes and cannot intersect anything. Content that grows after first paint — an image decoding, a lazily loaded clause, a web font swapping in — is handled by the browser for free, where the arithmetic version needs a scroll listener, a resize listener, a ResizeObserver and a wait on `document.fonts.ready` just to notice that the end moved. The observer has no `root`, which is the other half of the design: with the scrolling box as the root, a caller who forgets to cap its height gets a gate that opens instantly, because the end marker sits inside that box's own bounds whether or not any of it is on screen. Measured against the viewport, and clipped by every scroll container in between, one observer covers both layouts honestly — the end of a capped box counts when it is scrolled to *and* the box is on screen, and an uncapped document counts when the page reaches its last line. That is also stricter than any `scrollTop` sum, since the reader cannot satisfy a box whose bottom edge is below the fold. Once seen it stays seen. A reader who reaches the end and scrolls back up to re-read a clause has not undone anything, and a gate that closes behind them is the second most common complaint about this pattern after the one that never opens; `resetKey` is what puts the gate back, for a new version of the document rather than for a scroll position. The observer is thrown away the moment it latches, so a long page of these costs nothing once the reader has passed them. **It does not claim anybody read anything.** Displaying the last line is not reading it, this component cannot tell the difference, and the labels say only what is true (\"You've reached the end\", not \"You have read the terms\"). It records nothing, transmits nothing and proves nothing: it is an affordance that stops a reader clicking past a document they have not seen, not evidence of consent. There are readers for whom the end comes into view without any reading at all — a screen reader moving through the content, Cmd+End, a find-in-page hit near the bottom — and that is the right bias, because every alternative is a gate that traps somebody. The gated control stays focusable throughout, which is why `action` hands you `reached` and a `descriptionId` instead of rendering a button for you: a `disabled` button is removed from the tab order, so a screen reader user finds nothing where the submit should be and gets no explanation of what is missing, while `aria-disabled` with the click ignored leaves it discoverable and described by the status line. That status line is a single polite live region holding one of two strings, so reaching the end is announced once when it happens rather than as a percentage read out over the document the reader is trying to read; it also names the scrolling box through `aria-describedby`, so a reader who tabs into the box hears where they are in it. `scrollToEnd` is handed over too but deliberately not rendered — whether skipping to the end is acceptable is your call, not this component's. Built on pulld's `scroll-shadow`, so the box fades at the edge while there is more below, takes a tab stop only while it actually overflows, and carries its name and `region` role on the element that scrolls rather than on a wrapper nobody can reach. `useSeenOnce` is exported on its own: \"did this element ever come into view\", latched, which is the useful half of an IntersectionObserver and almost never what the API gives you, since entries arrive on the way out as well as the way in. Theme tokens throughout with no hardcoded colours, one lucide icon, and nothing else added. Official shadcn/ui has nothing here and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,287 bytes of it, the strings scrollHeight, scrollTop, clientHeight, IntersectionObserver, isIntersecting, consent, agree, accept and scrollIntoView are every one of them a zero hit. Official never measures a scroll position at all; its `scroll-area` hands that to Radix, and its `progress` is a bar you drive with a value you already have. Within pulld it sits beside scroll-progress and scroll-shadow, which report on a scroll without judging it, and beside unsaved-changes-guard and type-to-confirm, the other two components that stand between a reader and an action they cannot take back.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://pulld.pages.dev/r/scroll-shadow.json"
  ],
  "files": [
    {
      "path": "registry/ui/terms-scroll-gate.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { ScrollShadow } from \"@/registry/ui/scroll-shadow\"\n\n/** Every string a reader sees or hears. Override to translate or to reword. */\nexport interface TermsScrollGateLabels {\n  /** Shown while the end has not been on screen yet. */\n  more: string\n  /** Shown once it has. Deliberately about what was displayed, not about what was read. */\n  reached: string\n}\n\nexport const defaultTermsScrollGateLabels: TermsScrollGateLabels = {\n  more: \"Scroll to the end to continue\",\n  reached: \"You've reached the end\",\n}\n\nexport interface UseSeenOnceOptions {\n  /**\n   * Grows or shrinks the box the element has to enter, as an IntersectionObserver margin —\n   * `\"0px 0px -40px 0px\"` to require it a little further up the screen, past a sticky footer that\n   * would otherwise be covering it.\n   */\n  rootMargin?: string\n  /**\n   * Clears the latch when it changes. Use the identity of the thing being watched — a terms\n   * version, a document id — so that replacing the content puts the gate back, instead of the\n   * reader inheriting the state they earned on the previous document.\n   */\n  resetKey?: unknown\n}\n\n/**\n * Whether an element has been visible on screen at least once, latched.\n *\n * Exported because \"did this ever come into view\" is the useful half of an\n * IntersectionObserver and almost never what the API gives you — entries arrive on the way in\n * *and* the way out, so a plain `isIntersecting` in state is a different value a second later. A\n * one-way latch is what a view-counter, a lazy initialiser and this gate all actually want.\n *\n * The observer is thrown away the moment it latches, and nothing is observed again until\n * `resetKey` changes, so a long page of these costs nothing after the reader has passed them.\n */\nexport function useSeenOnce(\n  ref: React.RefObject<Element | null>,\n  { rootMargin = \"0px\", resetKey }: UseSeenOnceOptions = {}\n): boolean {\n  const [seen, setSeen] = React.useState(false)\n\n  // Adjusted during render rather than in an effect: an effect would let one render through with\n  // the previous document's latch still true, which for a consent gate is one render in which the\n  // button is live for terms that are no longer on screen.\n  const [lastKey, setLastKey] = React.useState(resetKey)\n  if (!Object.is(resetKey, lastKey)) {\n    setLastKey(resetKey)\n    setSeen(false)\n  }\n\n  React.useEffect(() => {\n    if (seen) return\n    const el = ref.current\n    if (!el) return\n\n    // Nothing to observe with. Latching open is the deliberate choice: a gate that can never\n    // open leaves the form unsubmittable, which is a worse failure than a gate that opens\n    // early — and this was never evidence of reading in the first place (see {@link\n    // TermsScrollGate}). Every browser that ships IntersectionObserver takes the path above.\n    if (typeof IntersectionObserver === \"undefined\") {\n      setSeen(true)\n      return\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) setSeen(true)\n      },\n      // No `root`, so the element is measured against the viewport and clipped by every scroll\n      // container in between. That is what makes one observer cover both layouts this component\n      // is dropped into — see the note on the component.\n      { rootMargin }\n    )\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [ref, rootMargin, seen])\n\n  return seen\n}\n\n/** What the gate hands to the control it is gating. */\nexport interface TermsScrollGateState {\n  /**\n   * Whether the end of the document has been on screen. Gate your control on this — and do it\n   * with `aria-disabled`, not `disabled`: see {@link TermsScrollGate}.\n   */\n  reached: boolean\n  /**\n   * The id of the status line, for `aria-describedby` on the control. This is how a screen\n   * reader user who lands on a control that does nothing finds out why.\n   */\n  descriptionId: string\n  /**\n   * Brings the end of the document into view. Not rendered as a button here — whether skipping\n   * to the end is acceptable is the caller's call, not this component's — but offered, because\n   * on a long document it is a kindness and in some flows it is a requirement.\n   */\n  scrollToEnd: () => void\n}\n\nexport interface TermsScrollGateProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The document: terms, a policy, release notes, a consent form. */\n  children: React.ReactNode\n  /**\n   * The control being gated, given the gate's state. Rendered under the box.\n   *\n   * ```tsx\n   * action={({ reached, descriptionId }) => (\n   *   <Button\n   *     aria-disabled={!reached}\n   *     aria-describedby={descriptionId}\n   *     onClick={reached ? accept : undefined}\n   *   >\n   *     I agree\n   *   </Button>\n   * )}\n   * ```\n   */\n  action?: (state: TermsScrollGateState) => React.ReactNode\n  /** Fires on each false → true transition, including after a reset. */\n  onReachEnd?: () => void\n  /** Clears the latch when it changes — a new version of the document. */\n  resetKey?: unknown\n  /** Passed through to {@link useSeenOnce}, for a sticky footer covering the last line. */\n  rootMargin?: string\n  /** Classes for the scrolling box itself: its border, its rounding, its background. */\n  boxClassName?: string\n  /** Classes for the scrolling element: `max-h-72`, padding, `prose`. */\n  viewportClassName?: string\n  labels?: Partial<TermsScrollGateLabels>\n}\n\n/**\n * A document that has to be scrolled to the end before the control under it will do anything —\n * the \"scroll to accept\" box on a terms page, a consent form, a release note nobody is allowed to\n * have missed.\n *\n * ```tsx\n * <TermsScrollGate\n *   aria-label=\"Terms of service\"\n *   resetKey=\"terms-2026-03\"\n *   action={({ reached, descriptionId }) => (\n *     <Button aria-disabled={!reached} aria-describedby={descriptionId} onClick={reached ? accept : undefined}>\n *       I agree\n *     </Button>\n *   )}\n * >\n *   <h2 className=\"font-medium\">Terms of service</h2>\n *   <p>…</p>\n * </TermsScrollGate>\n * ```\n *\n * **The arithmetic everybody writes for this is the wrong tool, and its failure is the one that\n * matters.** `scrollTop + clientHeight >= scrollHeight` is the obvious test, and it is three\n * different bugs. `scrollTop` is fractional while the other two are rounded integers, so at a\n * browser zoom other than 100% the sum lands a fraction of a pixel short of the total and the\n * agree button never enables, on a page where there is nothing the reader can do about it. A\n * document shorter than its box never scrolls at all, so the same test has to be special-cased or\n * a four-line policy locks the form forever. And a box that has not been laid out — in a dialog\n * that has not opened, a tab that is not selected, anything `display: none` — reports zeroes for\n * all three, which satisfies the test: the gate opens for terms that have never been on screen.\n *\n * So the end of the document is marked with an element and watched with an IntersectionObserver\n * instead, and all three dissolve rather than being fixed. Intersection is computed in layout\n * units with no rounding, a short document's end marker is simply already visible, and an element\n * in an unrendered subtree has no boxes and cannot intersect anything. Content that grows after\n * first paint — an image decoding, a lazily loaded clause, a web font swapping in — is handled by\n * the browser for free, where the arithmetic version needs a resize listener, a ResizeObserver and\n * a wait on `document.fonts.ready` to notice that the end moved.\n *\n * **The observer has no `root`, which is the other half of the design.** With the scrolling box as\n * the root, a caller who forgets to cap its height gets a gate that opens instantly: the end marker\n * is inside the box's own bounds whether or not any of it is on screen. Measured against the\n * viewport, and clipped by every scroll container in between, one observer covers both layouts\n * honestly — the end of a capped box counts when it is scrolled to *and* the box is on screen, and\n * an uncapped document counts when the page is scrolled to its last line. It is also stricter than\n * any `scrollTop` sum: the reader cannot satisfy a box whose bottom edge is below the fold.\n *\n * Once seen it stays seen. A reader who reaches the end and scrolls back up to re-read a clause has\n * not undone anything, and a gate that closes behind them is the second most common complaint about\n * this pattern after the one that never opens. `resetKey` is what puts the gate back, for a new\n * version of the document rather than for a scroll position.\n *\n * **It does not claim anybody read anything.** Displaying the last line is not reading it, this\n * component cannot tell the difference, and the labels are written to say only what is true. It\n * records nothing, proves nothing, and should not be cited as consent on its own: it is an\n * affordance that stops a reader clicking past a document they have not seen. There are readers for\n * whom the end marker becomes visible without any reading at all — a screen reader moving through\n * the content, `Cmd+End`, a find-in-page hit near the bottom — and that is the right bias. Every\n * alternative is a gate that traps somebody.\n *\n * The control stays focusable and reachable throughout, which is why `action` hands you `reached`\n * and a `descriptionId` rather than rendering a button for you: a `disabled` button is removed from\n * the tab order, so a screen reader user finds nothing where the submit should be and gets no\n * explanation of what is missing. `aria-disabled` with the click ignored leaves it discoverable and\n * described by the status line.\n */\nexport const TermsScrollGate = React.forwardRef<HTMLDivElement, TermsScrollGateProps>(\n  function TermsScrollGate(\n    {\n      children,\n      action,\n      onReachEnd,\n      resetKey,\n      rootMargin,\n      className,\n      boxClassName,\n      viewportClassName,\n      labels: labelOverrides,\n      \"aria-label\": ariaLabel,\n      \"aria-labelledby\": ariaLabelledBy,\n      ...props\n    },\n    ref\n  ) {\n    const labels = { ...defaultTermsScrollGateLabels, ...labelOverrides }\n    const endRef = React.useRef<HTMLDivElement>(null)\n    const reached = useSeenOnce(endRef, { rootMargin, resetKey })\n    const statusId = React.useId()\n\n    const onReachEndRef = React.useRef(onReachEnd)\n    onReachEndRef.current = onReachEnd\n\n    // Keyed off the previous value rather than off `reached` alone, so this is one call per\n    // transition however many times the effect runs — StrictMode's double invocation in\n    // development, or a re-render while the latch is still true.\n    const notifiedRef = React.useRef(false)\n    React.useEffect(() => {\n      if (reached === notifiedRef.current) return\n      notifiedRef.current = reached\n      if (reached) onReachEndRef.current?.()\n    }, [reached])\n\n    const scrollToEnd = React.useCallback(() => {\n      // `scrollIntoView` rather than scrolling the box by its own coordinates: the end marker may\n      // be out of view because the box is scrolled, because the page is, or because both are, and\n      // this walks up and scrolls every ancestor that needs it. \"nearest\" so a document that is\n      // already fully visible is not scrolled for no reason.\n      endRef.current?.scrollIntoView({ block: \"end\", inline: \"nearest\" })\n    }, [])\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\"flex min-h-0 flex-col gap-2\", className)}\n        data-reached={reached ? \"\" : undefined}\n        {...props}\n      >\n        <ScrollShadow\n          orientation=\"vertical\"\n          className={cn(\"rounded-md border bg-background\", boxClassName)}\n          viewportClassName={cn(\"max-h-72 p-4 text-sm\", viewportClassName)}\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          // So the reader who tabs into the box — rather than onto the control — hears where they\n          // are in it. The same line serves both, which is why there is one of it.\n          aria-describedby={statusId}\n        >\n          {children}\n          {/*\n            Inside the scroller and after the content, so it is the last thing to come into view.\n            A pixel tall because a zero-height element has no area to intersect with, and\n            `aria-hidden` because it is a measuring mark with nothing to say.\n\n            `shrink-0`: this lands in whatever layout the caller's content is in, and inside a flex\n            column it would otherwise be a flex item free to collapse to nothing.\n          */}\n          <div ref={endRef} aria-hidden=\"true\" className=\"h-px w-full shrink-0\" />\n        </ScrollShadow>\n\n        <div className=\"flex flex-wrap items-center justify-between gap-2\">\n          {/*\n            A live region holding one of two strings, so reaching the end is announced once, at the\n            moment it happens. Nothing here is driven by scroll position — a percentage updating as\n            the reader scrolls would be read out over the document they are trying to read.\n          */}\n          <p\n            id={statusId}\n            role=\"status\"\n            aria-live=\"polite\"\n            className={cn(\n              \"inline-flex items-center gap-1.5 text-xs\",\n              reached ? \"text-muted-foreground\" : \"font-medium text-foreground\"\n            )}\n          >\n            {reached ? null : <ArrowDown className=\"h-3.5 w-3.5 shrink-0\" aria-hidden=\"true\" />}\n            {reached ? labels.reached : labels.more}\n          </p>\n          {action?.({ reached, descriptionId: statusId, scrollToEnd })}\n        </div>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
