Fluxon.Components.ScrollArea
(Fluxon v3.0.0)
A wrapper that augments native scrolling with a custom, themeable scrollbar so containers look consistent across browsers and operating systems while keeping native scroll behavior intact.
Native scrollbars vary widely: macOS overlays them, Windows reserves space, Linux ships several
styles, and mobile browsers hide them altogether. scroll_area/1 gives the same look everywhere
by hiding the native scrollbar (only on fine-pointer devices) and rendering a thin overlay
scrollbar driven by the same scroll position. Wheel, trackpad, keyboard, and touch input all
continue to work the same as in any plain overflow: auto container, including momentum and
rubber-banding on touch devices.
On coarse-pointer devices (phones and tablets) the custom scrollbar is suppressed and the OS scrollbar is shown instead. Touch users do not interact with scrollbar tracks, and the native overlay scrollbars used by iOS and Android already match what users expect.
When to reach for scroll_area
Use scroll_area/1 for any container with overflow: auto where the default browser
scrollbar would visually clash with the design: sidebars built with Fluxon.Components.Navlist,
Fluxon.Components.Dropdown menus, Fluxon.Components.Modal bodies, long
Fluxon.Components.Popover panels, or Fluxon.Components.Table containers. For very large
lists where you also need windowing or infinite scroll, pair it with a virtualization layer
at the application level.
Usage
Constrain the height (or width, for horizontal scrolling) and pass any content as the inner block. The component decides per axis whether the content overflows and shows the matching scrollbar:
<.scroll_area class="h-72 w-72 rounded-base border border-base">
<div class="p-4 space-y-2">
<p :for={item <- @items}>{item.title}</p>
</div>
</.scroll_area>Horizontal scrolling works the same way once the inner content is wider than the viewport.
The internal content wrapper uses display: table; min-width: 100% so descendants take their
intrinsic width and trigger horizontal overflow when needed:
<.scroll_area class="w-96 rounded-base border border-base">
<div class="flex gap-3 p-3">
<div :for={card <- @cards} class="w-48 shrink-0 rounded-base border border-base p-3">
{card.title}
</div>
</div>
</.scroll_area>Interaction
Every native scroll gesture keeps working: wheel, trackpad, and touch scroll the viewport directly, and the overlay thumb follows along. On top of that, the custom scrollbar supports the same pointer gestures as a native one:
- Dragging a thumb scrolls the viewport proportionally, tracking the cursor from where the drag started so the content does not jump if it also moves for another reason mid-drag.
- Clicking an empty part of a track jumps the viewport so the clicked position is centered.
- Rolling the wheel while the pointer sits over a track forwards the scroll to the viewport, so the bar never blocks scrolling.
Only user-driven scrolling reveals the bars. Programmatic scrolling from application code
(scrollIntoView, setting scrollTop, an anchor jump) is detected and excluded, so calling
scrollTo from a LiveView hook or JS.dispatch does not flash the scrollbar.
When the viewport content overflows, the viewport becomes a keyboard tab stop that responds to
the arrow keys, PageUp/PageDown, Home, and End. This is on by default and controlled
per instance with keyboard_scrolling. See its attribute documentation for when to turn it off.
Visibility Modes
The type attribute controls when the custom scrollbar is visible. Pick the mode that matches
the surface: persistent surfaces benefit from auto so users always see scrollability, while
ephemeral surfaces like dropdowns work better with hover or scroll to stay visually quiet.
| Value | Behavior |
|---|---|
hover | Default. Scrollbars fade in while the pointer is over the area or while scrolling, and fade out after scroll_hide_delay. Best for most content. |
scroll | Scrollbars appear only during scrolling and stay for scroll_hide_delay after the last scroll event. Use when even a hovered scrollbar would feel noisy. |
always | Scrollbars are always visible regardless of overflow. Use sparingly; auto is usually a better default for "persistent" feel. |
auto | Scrollbars are visible whenever the corresponding axis overflows, and hidden when it does not. Closest to native browser behavior with custom styling. |
<.scroll_area class="h-72 w-72" type="auto">
<div class="p-4">{@long_text}</div>
</.scroll_area>Tune how long scrollbars linger after a scroll burst with scroll_hide_delay (only meaningful
for hover and scroll). The default of 600ms balances "still there if I want to grab it"
with "out of the way once I am reading".
<.scroll_area class="h-72" type="scroll" scroll_hide_delay={300}>
<ul class="p-2">
<li :for={item <- @items}>{item.title}</li>
</ul>
</.scroll_area>Styling
The component exposes data-* state on the root and on each scrollbar so you can theme every
state with Tailwind variants instead of conditional classes:
| Attribute | On | Meaning |
|---|---|---|
data-overflow-x / -y | root | Set when the matching axis has scrollable overflow. |
data-overflow-x-start / -end, data-overflow-y-start / -end | root | Set when the user has scrolled away from the matching edge. Useful for fade masks at the edge that should appear only once content is hidden behind it. |
data-hovering | root, scrollbar | Pointer is currently over the scroll area. |
data-scrolling | root | A user-driven scroll is in progress (cleared after scroll_hide_delay). Programmatic scrolls are excluded so app-level scrollTo does not flash the bars. |
data-orientation | scrollbar | vertical or horizontal. |
data-state | scrollbar | visible or hidden, driven by the active type. |
The component also writes a few CSS custom properties so you can build mask gradients and scrollbar-aware layouts without re-implementing the math:
| Variable | On | Meaning |
|---|---|---|
--scroll-area-thumb-height / --scroll-area-thumb-width | scrollbar | Pixel size of the corresponding thumb after the latest measurement. |
--scroll-area-overflow-y-start / -end, --scroll-area-overflow-x-start / -end | viewport | Pixels scrolled away from the matching edge. Pair with mask-image for progressive edge fades. |
The internal scrollbar tracks and thumbs are styled with the same bg-base, bg-foreground-*,
and rounded-corner tokens used elsewhere in the library. Separate class hooks let you override
the look on a single instance:
| Attribute | Targets |
|---|---|
class | Root container (size, border, surface). |
viewport_class | Scrolling viewport (inner padding that should scroll with content). |
scrollbar_class | Both scrollbar tracks (track width, padding, color). |
thumb_class | Both thumbs (color, rounding, hover/active states). |
<.scroll_area
class="h-72 w-72 rounded-base border border-base bg-surface"
viewport_class="p-4"
scrollbar_class="data-[orientation=vertical]:w-2"
thumb_class="bg-primary/40 hover:bg-primary"
type="auto"
>
{@content}
</.scroll_area>The thumb's along-scroll size (height for vertical, width for horizontal) is computed at runtime
from the viewport-to-content ratio, so it cannot be set via class. Use scrollbar_class to
resize the perpendicular axis (track width on a vertical bar, track height on a horizontal one).
Touch Devices
On coarse-pointer environments the custom scrollbar markup is left in the DOM but is suppressed via media queries, and the native scrollbar is shown instead. This keeps the component out of the user's way on touch and lets the OS handle momentum, rubber-banding, and edge fades the way users expect. No JavaScript runs to style the scrollbar in this mode.
Examples
Sidebar navigation that stays inside the viewport and scrolls when items overflow:
<aside class="flex h-screen w-64 flex-col border-r border-base">
<div class="p-4 font-semibold">Workspace</div>
<.scroll_area class="flex-1" type="hover">
<.navlist>
<.navlist_item :for={item <- @nav}>{item.label}</.navlist_item>
</.navlist>
</.scroll_area>
</aside>Long-form modal body that scrolls inside a fixed-height surface:
<.modal id="terms">
<:title>Terms of service</:title>
<.scroll_area class="h-[60vh]" type="auto">
<div class="prose p-6">{@terms_html}</div>
</.scroll_area>
</.modal>Horizontal card carousel inside a card section:
<section class="rounded-base border border-base">
<h3 class="border-b border-base p-3 font-medium">Recently viewed</h3>
<.scroll_area type="scroll" scroll_hide_delay={400}>
<div class="flex gap-3 p-3">
<article :for={item <- @recent} class="w-56 shrink-0 rounded-base border border-base p-3">
<h4 class="font-medium">{item.title}</h4>
<p class="mt-1 text-foreground-softer text-sm">{item.subtitle}</p>
</article>
</div>
</.scroll_area>
</section>
Summary
Components
Renders a scroll area: a sized container whose inner content scrolls with a custom, themeable scrollbar instead of the browser's native one on fine-pointer devices.
Components
Renders a scroll area: a sized container whose inner content scrolls with a custom, themeable scrollbar instead of the browser's native one on fine-pointer devices.
Set the height (or width) on class so the viewport has a constraint to scroll against;
without one the content simply expands and there is nothing to scroll. The component decides
per axis whether to show a scrollbar based on actual overflow.
Attributes
id(:string) - Identifier for the scroll area root. Auto-generated when omitted. The JavaScript controller scopes its DOM queries to this element, so nesting scroll areas inside one another works without cross-talk.class(:any) - Additional CSS classes merged onto the root element. This is where you set the size of the scroll area (h-*,w-*,max-h-*) and any visual chrome like borders, rounding, or background.Defaults to
nil.viewport_class(:any) - Additional CSS classes merged onto the scrolling viewport element. Use this for inner padding (p-*) so the padding scrolls with the content, instead of sitting outside on the root.Defaults to
nil.scrollbar_class(:any) - Additional CSS classes merged onto each scrollbar track (both vertical and horizontal). Use this to widen or narrow the track (w-2,h-2), recolor it, or add padding around the thumb. The default track is 6px wide with a 2px inset.Defaults to
nil.thumb_class(:any) - Additional CSS classes merged onto each scrollbar thumb (both axes). Use this to recolor the thumb, change its rounding, or alter its hover and active states. The thumb's along-scroll size is computed from content/viewport ratios at runtime and cannot be overridden via class.Defaults to
nil.type(:string) - Controls when the custom scrollbar is visible. Defaults to"hover".hover: fades in while the pointer is over the area or while scrolling, then fades out afterscroll_hide_delay. Best default for most content.scroll: appears only during scrolling and lingers forscroll_hide_delayafterward. Use on quiet, ephemeral surfaces like dropdowns where even a hovered bar feels noisy.always: permanently visible regardless of overflow, filling the track as a flat bar when there is nothing to scroll. Use sparingly;autousually gives a better persistent feel.auto: visible whenever the matching axis overflows and hidden when it does not, closest to native browser scrollbars with custom styling.
Defaults to
"hover". Must be one of"hover","scroll","always", or"auto".scroll_hide_delay(:integer) - Milliseconds the scrollbar lingers after the last scroll event before fading out. Only meaningful fortype="hover"andtype="scroll".Defaults to
600.keyboard_scrolling(:boolean) - Whether the viewport receivestabindex="0"when its content overflows, making it a tab stop that responds to arrow keys,PageUp/PageDown,Home, andEnd. Defaults totrueso keyboard users can scroll content that has no other focusable children. Set tofalsefor scroll areas whose contents are already keyboard-reachable (sidebars full of links, code blocks that scroll horizontally without interactive content) so the viewport does not introduce an extra, redundant tab stop.Defaults to
true.Global attributes are accepted. Additional HTML attributes forwarded to the root element.
Slots
inner_block(required) - Content rendered inside the scrolling viewport.