PetalComponents.ScrollArea (petal_components v4.16.1)

Copy Markdown View Source

A themed scroll container, so an overflow region looks like it belongs to the design system instead of inheriting whatever the OS decided.

It is one <div> and zero JavaScript. Modern scrollbar-width and scrollbar-color do the work where the engine honours them (Chrome 121+, Firefox), and a ::-webkit-scrollbar block covers older Chromium and current Safari. The thumb rides the gray ramp in both light and dark mode and steps one shade toward the foreground on hover.

Size the viewport with classes, not attrs - that is deliberate, so the component never grows a parallel sizing vocabulary next to Tailwind's:

<.scroll_area class="max-h-72 rounded-lg border border-gray-200 p-4 dark:border-gray-800">
  <p :for={item <- @items}>{item}</p>
</.scroll_area>

Scroll sideways instead, and hint at the content past the edge:

<.scroll_area orientation="horizontal" fade_edges class="w-full pb-2">
  <div class="flex gap-2">
    <.badge :for={tag <- @tags} label={tag} />
  </div>
</.scroll_area>

Both axes at once, with the scrollbar space reserved so the content does not shift the moment a scrollbar appears:

<.scroll_area orientation="both" gutter_stable class="max-h-64 max-w-full">
  <pre><code>{@snippet}</code></pre>
</.scroll_area>

The platform truth

Scrollbars are the operating system's, not ours. On macOS with "Show scroll bars: Automatically" - the default - the scrollbar is an overlay that appears while you scroll, sits on top of the content, ignores most theming and has no gutter to reserve. Most mobile browsers behave the same way. On Windows and Linux, and on macOS set to "Always", you get a classic scrollbar that takes real layout space and picks up the theming in full.

So <.scroll_area> themes what the platform exposes. It does not fight the OS, and it will not make a Mac look like a PC:

  • visibility="always" is a request. WebKit and Chromium honour it: always-mode resets scrollbar-width/scrollbar-color to auto, which drops those engines onto the ::-webkit-scrollbar path, and an explicit size there opts the element out of overlay rendering. Firefox has no force-visible mechanism at all, so there the request is silently ignored - and because of the reset, an always-mode area on Firefox also renders the platform-default scrollbar rather than the thin themed one. Always-visible where possible beats thin-but-vanishing; that is the trade.
  • gutter_stable is a no-op wherever scrollbars are overlays, because an overlay has no gutter to reserve. It is not broken; there is simply nothing to reserve.
  • scrollbar-color has no hover state, so on Chrome 121+ and Firefox the thumb keeps one colour. The hover step only shows on engines taking the ::-webkit-scrollbar path.

Fade edges

fade_edges applies a mask-image gradient on the scrolling axis (both, composited, when orientation="both"). The mask is static: it fades both edges regardless of scroll position, so content that fits without scrolling still gets softened at its edges. That is the v1 trade - scroll-position-aware fading needs either JS or animation-timeline, and neither earns its keep yet. Reach for fade_edges on regions that genuinely overflow.

The mask is decorative and adds no DOM, so there is nothing for assistive tech to skip. It does apply to the whole element, though, border and background included - so put the border on a wrapper rather than on the scroll area itself when you combine the two, or you will watch the border fade out along with the content:

<div class="rounded-lg border border-gray-200 p-4 dark:border-gray-800">
  <.scroll_area fade_edges class="max-h-56">...</.scroll_area>
</div>

One exception is built in: while the area holds keyboard focus (:focus-visible), the fade lifts entirely - the mask's painting area includes the focus ring, and a mask that erased the ring would leave keyboard users navigating blind. The fade returns the moment focus moves on.

Accessibility

There is no ARIA pattern for a scroll container. What matters is that a keyboard user can reach the content and move it:

  • The container renders tabindex="0", which is what makes arrow keys, Page Up/Down, Home and End scroll it. That is native browser behaviour and needs no JS. Being focusable, it takes the standard focus-visible ring - never a persistent focus fill.
  • Pass tabindex="-1" to remove the tab stop when everything inside is already focusable (a list of links, a menu). A second tab stop in front of focusable content is noise; a tab stop in front of a wall of unreachable text is the only way in.
  • Give it a name when it is a standalone region: <.scroll_area aria-label="Chat messages">. With aria-label or aria-labelledby present the container also renders role="region", so it lands in the landmark list under that name. Without a name no role is emitted - an unnamed region is landmark noise, not a service. An explicit role you pass always wins.

Summary

Functions

A themed scroll container: native scrollbars, styled to match.

Functions

scroll_area(assigns)

A themed scroll container: native scrollbars, styled to match.

See PetalComponents.ScrollArea for usage, the platform caveats around overlay scrollbars, and the accessibility notes.

Attributes

  • orientation (:string) - which axis scrolls: vertical (overflow-y), horizontal (overflow-x), or both. Defaults to "vertical". Must be one of "vertical", "horizontal", or "both".
  • fade_edges (:boolean) - fade content out at the scroll edges with a mask-image gradient, hinting that more content exists past the clip. Masks apply on the scrolling axis only, and are static - they do not track scroll position. Defaults to false.
  • gutter_stable (:boolean) - reserve scrollbar space with scrollbar-gutter: stable so content does not shift when the scrollbar appears or disappears. Classic scrollbars only - overlay scrollbars have no gutter to reserve. Defaults to false.
  • visibility (:string) - auto follows the platform (overlay scrollbars appear on scroll on macOS); always requests a persistently visible scrollbar where the engine allows it - see the platform-truth note in the module docs. Defaults to "auto". Must be one of "auto", or "always".
  • class (:any) - size the viewport here, e.g. class="max-h-72" or class="max-w-full" - sizing is deliberately class-driven, not attr-driven. Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)