VESYL UI

ScrollHint

A bounded scroll region gives no sign that it is cut off — the content just ends at the edge. ScrollHint marks an edge that has more past it.

Reaching for this directly is the exception. ScrollArea already renders hints on both axes, and DialogBody is that with a dialog’s padding filled in. Between them they cover a bounded region and a dialog body.

These pieces are for a plain overflow element you own. useScrollEdges owns the measuring and ScrollHint is presentation only, so a design that wants the measurement with a different treatment — a fade mask, a shadow, a “42 more” count — can take just the hook.

import { ScrollHint, useScrollEdges } from '@vesyl/ui-next'

function Panel() {
  const { ref, scrollable, atTop, atBottom } = useScrollEdges()

  return (
    // relative goes on the WRAPPER — on the scroll box the hints would
    // scroll away with the content.
    <div className="relative">
      <div ref={ref} className="max-h-64 overflow-y-auto">
        {/* … */}
      </div>
      <ScrollHint edge="top" show={scrollable && !atTop} />
      <ScrollHint edge="bottom" show={scrollable && !atBottom} />
      {/* …or pass onActivate to make the marker scroll as well as signal:
          onActivate={() => scrollByPage('down')} */}
    </div>
  )
}

Gate both hints on scrollable, or they appear on content that fits.

Both edges

Paragraph 1. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

Paragraph 2. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

Paragraph 3. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

Paragraph 4. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

Paragraph 5. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

Paragraph 6. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

Paragraph 7. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

Paragraph 8. Scroll and watch the chevrons: the bottom one fades out as you reach the end, and the top one appears once there is content above.

The marker sits on a blurred chip rather than being a bare icon: it has to read over whatever happens to be beneath it, and an icon alone vanishes against text.

Both hints above take onActivate, so clicking scrolls one screenful that way.

Content that grows

Row 1

Row 2

Content can become scrollable without anyone scrolling — a form revealing fields, a list appending rows, an image settling — and that is exactly when the hint earns its place, before the reader has any way to know there is more. A hook listening only for scroll would appear after the gesture it was meant to prompt.

Resize covers the box or a child changing size. Appending a child changes neither, so the child list is observed as well — that is what the demo above exercises.

useScrollEdges

Returns
refAttach to the scrolling element.
scrollableContent is taller than the box.
atTop / atBottomWhether that edge is reached, with two pixels of slack.
scrollableXContent is wider than the box. Tracked separately — a region can overflow one way and fit the other, and hinting the axis that fits points at content that is not there.
atLeft / atRightThe inline-axis equivalents.
scrollByPage(dir)Scrolls one screenful 'up', 'down', 'left' or 'right', smoothly — by the box’s height on the block axis and its width on the inline one.
scrollbarTakesSpaceA scrollbar occupies layout width here — classic scrollbars, or overlay ones under scrollbar-gutter: stable.

scrollbarTakesSpace is how to keep the hint from doubling up on a signal the platform already gives. Where the scrollbar is permanently visible it says “this scrolls” on its own, so gate on it:

const { scrollable, atBottom, scrollbarTakesSpace } = useScrollEdges()
const hint = scrollable && !scrollbarTakesSpace

DialogBody does exactly this. It measures the element rather than probing the OS, so scrollbar-gutter and a per-element scrollbar-width are accounted for — and it survives the macOS setting that reveals scrollbars when a mouse is connected, which can flip mid-session.

scrollByPage moves 85% of the visible extent rather than a full screen, so a line or two carries over and the reader keeps their place.

Measure both axes, but only hint the one you actually scroll. overflow: hidden still makes a scroll container, so scrollableX reads true on a vertical region whose child is too wide — hinting it would offer a control for movement the user cannot make. ScrollArea gates on its axis for this reason.

ref is a callback ref, so a container that mounts later — inside a dialog, popover, or any conditional branch — is measured when it appears. An object ref read once in an effect would find null and never look again, which is the bug this shape avoids.

The two pixels of slack are not arbitrary: fractional layout leaves a fully scrolled box a hair short of scrollHeight, so an exact comparison leaves the bottom hint lit at the bottom.

ScrollHint

PropType
showbooleanFades in when true. Always rendered, so there is no layout shift.
edge'top' | 'bottom' | 'left' | 'right'Which edge to sit on. Defaults to bottom. One chevron, rotated — and the chip’s padding swaps with the axis so it is the same pill turned sideways.
onActivate() => voidRenders a button that calls this on click. Omit for a decorative hint.
classNamestringMerged onto the chip.

The chip is a translucent blur with no border — separation without reading as a control. The chevron is stretched horizontally so it marks an edge rather than looking like a small button. The “Content that grows” demo above omits onActivate — that is the decorative shape.

Accessibility

ScrollHint is aria-hidden — and stays that way even with onActivate, where it renders a <button tabIndex={-1}>. The scroll container is already reachable and scrollable by keyboard, so this is a redundant pointer affordance; exposing it would add tab stops for a gesture that already exists. Base UI’s own select scroll buttons work the same way.

Keep the region itself keyboard-reachable. If it holds no focusable content, give it tabindex="0" so it can be scrolled without a pointer.