For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Sponsor

useScrollEdges

Detect overflow and whether a scroll viewport has reached either edge.

useScrollEdges describes where a scrollable viewport is relative to its content. It answers whether there is any overflow, whether the viewport is at the beginning, and whether it has reached the end.

Those are different questions. A short list can be at both ends because it does not overflow. A long list at its beginning can still have more content ahead. Keeping the distinction prevents an unnecessary fade or an enabled “next” affordance when there is nowhere to scroll.

Observe scrolling, do not replace it

Use the hook to show edge fades, enable directional controls, or indicate that more content is available. The hook does not move the viewport or intercept wheel, touch, or keyboard events. Scrollbars uses it to decide which fades are needed.

Attach its ref to the element that actually scrolls, not an outer wrapper. Choose the axis explicitly for a horizontal collection. A small threshold accounts for fractional scroll positions so the final pixel does not leave an end indicator stuck on.

Scroll the preview from one end to the other and compare the three values. Switch the axis to see the same model used for a vertical list.

Preview

Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
atStart: true · atEnd: true · canScroll: false

Installation

The starter includes hooks/use-scroll-edges.ts. Import the hook directly; no application-wide provider is required.

Usage

import { useScrollEdges } from "@/hooks/use-scroll-edges";
 
const { ref, atStart, atEnd, canScroll } = useScrollEdges({
  orientation: "horizontal",
});
<div ref={ref} className="overflow-auto">
  {children}
</div>;

API Reference

APITypeBehavior / default
orientationhorizontal or verticalvertical
thresholdnumber1px tolerance for fractional scrolling
refCallback refAttach to the scrolling element
atStartbooleanAt the leading edge
atEndbooleanAt the trailing edge
canScrollbooleanContent exceeds the viewport
update()FunctionRecalculate explicitly

Behavior

No overflow means both atStart and atEnd are true. The hook observes the viewport and its children, listens passively for scrolling, and checks content mutations. ScrollArea reuses it for fades. Horizontal RTL uses the magnitude of scrollLeft so logical start is handled by modern browsers' negative RTL offsets.

Implementation

"use client";

import { useCallback, useEffect, useState } from "react";

export const useScrollEdges = <T extends HTMLElement = HTMLDivElement>({
  orientation = "vertical",
  threshold = 1,
}: { orientation?: "horizontal" | "vertical"; threshold?: number } = {}) => {
  const [element, setElement] = useState<T | null>(null);
  const [edges, setEdges] = useState({
    atEnd: true,
    atStart: true,
    canScroll: false,
  });
  const update = useCallback(() => {
    if (!element) {
      return;
    }
    const horizontal = orientation === "horizontal";
    const position = horizontal
      ? Math.abs(element.scrollLeft)
      : element.scrollTop;
    const maximum = horizontal
      ? element.scrollWidth - element.clientWidth
      : element.scrollHeight - element.clientHeight;
    const next = {
      atEnd: position >= maximum - threshold,
      atStart: position <= threshold,
      canScroll: maximum > threshold,
    };
    setEdges((previous) =>
      previous.atStart === next.atStart &&
      previous.atEnd === next.atEnd &&
      previous.canScroll === next.canScroll
        ? previous
        : next
    );
  }, [element, orientation, threshold]);
  useEffect(() => {
    if (!element) {
      return;
    }
    const resize = new ResizeObserver(update);
    const observe = () => {
      resize.disconnect();
      resize.observe(element);
      for (const child of element.children) {
        resize.observe(child);
      }
      update();
    };
    const mutation = new MutationObserver(observe);
    mutation.observe(element, {
      characterData: true,
      childList: true,
      subtree: true,
    });
    observe();
    element.addEventListener("scroll", update, { passive: true });
    return () => {
      resize.disconnect();
      mutation.disconnect();
      element.removeEventListener("scroll", update);
    };
  }, [element, update]);
  return { ref: setElement, ...edges, update };
};