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 resetsscrollbar-width/scrollbar-colortoauto, which drops those engines onto the::-webkit-scrollbarpath, 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_stableis 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-colorhas no hover state, so on Chrome 121+ and Firefox the thumb keeps one colour. The hover step only shows on engines taking the::-webkit-scrollbarpath.
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 standardfocus-visiblering - 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">. Witharia-labeloraria-labelledbypresent the container also rendersrole="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 explicitroleyou pass always wins.
Summary
Functions
A themed scroll container: native scrollbars, styled to match.
Functions
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 tofalse.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 tofalse.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 tonil.- Global attributes are accepted.
Slots
inner_block(required)