ScrollArea
A styleable scrollbar over ordinary native scrolling. Choose which axes may scroll, when the bar appears, and how it looks, and bind the viewport when you need to drive the scroll position yourself.
Playground
Experiment with different props in real-time.
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Only the axes named by the orientation prop can scroll. The other one is clipped, so a stray wide child cannot quietly introduce a second scrollbar you never asked for.
Scrollbar visibility is a separate question from orientation. An area can scroll on both axes and still only reveal its bars while the pointer is over it.
The last thing to know is that a scroll area has no height of its own. Give it one, or put it in a flex parent that constrains it, or it will simply grow and never scroll.
Basic Usage
A scroll area has no height of its own, so give it one. Without a bounded height, a max-height, or a flex parent that constrains it, there is nothing to overflow and no bar appears.
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Only the axes named by the orientation prop can scroll. The other one is clipped, so a stray wide child cannot quietly introduce a second scrollbar you never asked for.
Scrollbar visibility is a separate question from orientation. An area can scroll on both axes and still only reveal its bars while the pointer is over it.
The last thing to know is that a scroll area has no height of its own. Give it one, or put it in a flex parent that constrains it, or it will simply grow and never scroll.
<script lang="ts">
import { ScrollArea } from 'sv5ui';
</script>
<!-- The scroll area needs a bounded height to have anything to scroll:
a fixed height, a max-height, or a flex parent that constrains it. -->
<ScrollArea class="h-64 rounded-xl border border-outline-variant/50 p-4">
<p>Long content...</p>
</ScrollArea>Orientation
orientation decides which axes may scroll. The other axis is clipped rather than left to overflow, so an
over-wide child cannot introduce a scrollbar you did not ask for. Only both renders the corner between the two bars.
Vertical
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Only the axes named by the orientation prop can scroll. The other one is clipped, so a stray wide child cannot quietly introduce a second scrollbar you never asked for.
Horizontal
Both
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Only the axes named by the orientation prop can scroll. The other one is clipped, so a stray wide child cannot quietly introduce a second scrollbar you never asked for.
<!-- vertical (default): the horizontal axis is clipped -->
<ScrollArea class="h-48">...</ScrollArea>
<!-- horizontal: the vertical axis is clipped -->
<ScrollArea orientation="horizontal" class="w-full">
<div class="flex w-max gap-3">...</div>
</ScrollArea>
<!-- both: two scrollbars plus a corner between them -->
<ScrollArea orientation="both" class="h-64">
<div class="w-max">...</div>
</ScrollArea>Visibility
type controls when the bar is on screen. hover and scroll hide it again after scrollHideDelay milliseconds; auto behaves like a native bar and shows whenever the content overflows; always never
hides, even with nothing to scroll.
Hover
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Scroll
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Auto
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Always
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
<!-- hover (default): visible while the pointer is over the area,
then hidden after scrollHideDelay -->
<ScrollArea type="hover" scrollHideDelay={600}>...</ScrollArea>
<!-- scroll: visible while scrolling, then hidden after scrollHideDelay -->
<ScrollArea type="scroll" scrollHideDelay={1000}>...</ScrollArea>
<!-- auto: visible whenever the content overflows, like a native scrollbar.
scrollHideDelay does not apply. -->
<ScrollArea type="auto">...</ScrollArea>
<!-- always: permanently visible, even when nothing overflows -->
<ScrollArea type="always">...</ScrollArea>Appearance
color tints the thumb and size sets its thickness. track keeps the track tinted rather than waiting for a hover, and ui reaches
each slot directly.
Primary, lg
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Success, track
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Slot override
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
<!-- color tints the thumb, size sets the bar thickness -->
<ScrollArea color="primary" size="lg">...</ScrollArea>
<!-- track keeps the track tinted instead of only on hover -->
<ScrollArea track>...</ScrollArea>
<!-- transition={false} drops the fade in and out -->
<ScrollArea type="scroll" transition={false}>...</ScrollArea>
<!-- Slot classes -->
<ScrollArea
ui={{
viewport: 'p-4',
thumb: 'bg-primary/60 hover:bg-primary'
}}
>...</ScrollArea>Viewport Ref
The root is overflow-hidden and never scrolls, so ref is not the element you want for scroll work. Bind viewportRef instead. That is also why rest props, which land on the root, cannot carry an onscroll handler: listen on the viewport.
A scroll area replaces the browser scrollbar with one you can style, without giving up native scrolling: the wheel, trackpad, touch drag, keyboard and text selection all behave exactly as they would in an ordinary overflow container.
The root element is never the thing that scrolls. It clips, positions the scrollbars and hosts the corner. Inside it sits a viewport, and the viewport is what overflows.
That split is worth remembering, because it decides where events fire and where a scroll offset is read from. Anything that measures or moves the scroll position needs the viewport.
Only the axes named by the orientation prop can scroll. The other one is clipped, so a stray wide child cannot quietly introduce a second scrollbar you never asked for.
Scrollbar visibility is a separate question from orientation. An area can scroll on both axes and still only reveal its bars while the pointer is over it.
The last thing to know is that a scroll area has no height of its own. Give it one, or put it in a flex parent that constrains it, or it will simply grow and never scroll.
<script lang="ts">
import { ScrollArea, Button } from 'sv5ui';
// viewportRef is the element that actually scrolls. The root is not
// scrollable, so bind this rather than ref for scroll work.
let viewport = $state<HTMLDivElement | null>(null);
function toTop() {
viewport?.scrollTo({ top: 0, behavior: 'smooth' });
}
function toBottom() {
viewport?.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' });
}
</script>
<Button label="Top" onclick={toTop} />
<Button label="Bottom" onclick={toBottom} />
<ScrollArea bind:viewportRef={viewport} class="h-64">...</ScrollArea>Infinite Scroll
useInfiniteScroll measures the element it is attached to, so it needs the viewport. There is no use: directive that reaches inside the component, so hand the action the bound ref from an effect
instead. Scroll to the bottom of the list below to load more.
<script lang="ts">
import { ScrollArea, useInfiniteScroll } from 'sv5ui';
let viewport = $state<HTMLDivElement | null>(null);
let items = $state(initialItems);
let hasMore = $state(true);
const scroll = useInfiniteScroll({
onLoad: async () => {
const next = await loadNextPage();
items = [...items, ...next];
if (next.length === 0) hasMore = false;
},
enabled: () => hasMore
});
// The action listens on the node it is given and measures scrollTop
// against scrollHeight, so it belongs on the viewport: the root is
// overflow-hidden and never scrolls. There is no use: directive to
// reach the viewport with, so hand the action the bound ref instead.
// The effect re-runs if the viewport is ever swapped out.
$effect(() => {
if (viewport) scroll.action(viewport);
});
</script>
<ScrollArea bind:viewportRef={viewport} class="h-96">
{#each items as item (item.id)}
<Row {item} />
{/each}
{#if scroll.loading}
<Icon name="lucide:loader-circle" class="animate-spin" />
{/if}
</ScrollArea>Restoring Position
The same rule applies to reading the offset back. useEventListener accepts a getter, so it attaches itself as soon as the ref is populated and reattaches if the
viewport is ever replaced.
Write the offset on scroll, read it back in an effect once viewportRef exists.
<script lang="ts">
import { ScrollArea, useEventListener } from 'sv5ui';
let viewport = $state<HTMLDivElement | null>(null);
// Listen on the viewport, not on <ScrollArea>: rest props land on the
// root, and the root is overflow-hidden, so an onscroll there never
// fires. useEventListener takes a getter and reattaches on its own
// once the ref is populated.
useEventListener(
() => viewport,
'scroll',
() => sessionStorage.setItem('feed-scroll', String(viewport?.scrollTop ?? 0)),
{ passive: true }
);
$effect(() => {
if (!viewport) return;
viewport.scrollTop = Number(sessionStorage.getItem('feed-scroll') ?? 0);
});
</script>
<ScrollArea bind:viewportRef={viewport} class="h-64">...</ScrollArea>Inside Other Components
Five components scroll inside a scroll area of their own, and each accepts a scrollArea prop that forwards behavior into it: orientation, type, scrollHideDelay, color, size, track and transition. Styling stays on the host component's own ui slots, which
is why they are not part of that prop.
<!-- Table, Sidebar, Slideover, NavigationMenu and Lightbox scroll
inside a ScrollArea of their own. The scrollArea prop forwards
behavior into it: orientation, type, scrollHideDelay, color,
size, track and transition. -->
<Table {columns} {rows} scrollArea={{ type: 'always', size: 'md' }} />
<Sidebar {items} scrollArea={{ type: 'scroll' }} />
<Slideover scrollArea={{ color: 'primary', track: true }}>...</Slideover>
<!-- Styling stays on the host component's own ui slots, which is why
the wrapper is exposed there rather than through scrollArea. -->
<Table {columns} {rows} ui={{ scroll: 'max-h-96' }} />Global Configuration
Set defaults once and every scroll area picks them up, including the ones nested inside the five components above.
See Theming for the full configuration surface.
import { defineConfig } from 'sv5ui';
defineConfig({
scrollArea: {
defaultVariants: { color: 'primary', size: 'md', track: true },
slots: { thumb: 'rounded-sm' }
}
});Host Components
Components that scroll inside a ScrollArea and accept a scrollArea prop.
| Component | What scrolls |
|---|---|
Table | The table itself, on both axes. Also exposes a scroll ui slot |
Sidebar | The content column below the header |
Slideover | The body, when a body snippet is passed |
NavigationMenu | The horizontal list. Also exposes a scroll ui slot |
Lightbox | The thumbnail strip. Also exposes a thumbnailsScroll ui slot |
UI Slots
Use the ui prop to override classes.
| Slot | Description |
|---|---|
root | Outer wrapper. Clips the content and positions the scrollbars |
viewport | The element that actually scrolls |
content | Wrapper around the children, inside the viewport |
scrollbar | Scrollbar track, one per enabled axis |
thumb | Draggable thumb inside the track |
corner | Square between the two bars, only when orientation is both |
Props
Every other prop is forwarded to the root element.
| Prop | Type | Default |
|---|---|---|
orientation | 'vertical' | 'horizontal' | 'both' | 'vertical' |
type | 'hover' | 'scroll' | 'auto' | 'always' | 'hover' |
scrollHideDelay | number | 600 |
color | primary | secondary | tertiary | success | warning | error | info | surface | 'surface' |
size | 'xs' | 'sm' | 'md' | 'lg' | 'sm' |
track | boolean | false |
transition | boolean | true |
viewportRef | HTMLDivElement | null | null |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |