Layout

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

1
2
3
4
5
6
7
8
9
10

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.

1
Item number 1
2
Item number 2
3
Item number 3
4
Item number 4
5
Item number 5
6
Item number 6
7
Item number 7
8
Item number 8
9
Item number 9
10
Item number 10
11
Item number 11
12
Item number 12
13
Item number 13
14
Item number 14
15
Item number 15
<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.

ComponentWhat scrolls
TableThe table itself, on both axes. Also exposes a scroll ui slot
SidebarThe content column below the header
SlideoverThe body, when a body snippet is passed
NavigationMenuThe horizontal list. Also exposes a scroll ui slot
LightboxThe thumbnail strip. Also exposes a thumbnailsScroll ui slot

UI Slots

Use the ui prop to override classes.

SlotDescription
rootOuter wrapper. Clips the content and positions the scrollbars
viewportThe element that actually scrolls
contentWrapper around the children, inside the viewport
scrollbarScrollbar track, one per enabled axis
thumbDraggable thumb inside the track
cornerSquare between the two bars, only when orientation is both

Props

Every other prop is forwarded to the root element.

PropTypeDefault
orientation'vertical' | 'horizontal' | 'both''vertical'
type'hover' | 'scroll' | 'auto' | 'always''hover'
scrollHideDelaynumber600
colorprimary | secondary | tertiary | success | warning | error | info | surface'surface'
size'xs' | 'sm' | 'md' | 'lg''sm'
trackbooleanfalse
transitionbooleantrue
viewportRefHTMLDivElement | nullnull
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-