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.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/terms-scroll-gate.json"