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 | |
|---|---|
ref | Attach to the scrolling element. |
scrollable | Content is taller than the box. |
atTop / atBottom | Whether that edge is reached, with two pixels of slack. |
scrollableX | Content 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 / atRight | The 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. |
scrollbarTakesSpace | A 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 && !scrollbarTakesSpaceDialogBody 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
| Prop | Type | |
|---|---|---|
show | boolean | Fades 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 | () => void | Renders a button that calls this on click. Omit for a decorative hint. |
className | string | Merged 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.