ShardsUI is in beta. APIs may change before 1.0.

Scroll Area

A styleable scroll region.

<script lang="ts">
  import { ScrollArea } from '@shardsui/svelte/scroll-area'
</script>

<ScrollArea.Root class="h-34 w-96 max-w-[calc(100vw-8rem)]">
  <ScrollArea.Viewport
    class="h-full rounded-md outline-1 -outline-offset-1 outline-gray-200 focus-visible:outline-2 focus-visible:outline-gray-950"
  >
    <ScrollArea.Content class="flex flex-col gap-4 py-3 pr-6 pl-4 text-sm/5.5 text-gray-900">
      <p>
        Reaching for a div and styling it into a button is where most accessibility debt starts. A
        native button ships with a role, keyboard activation, focus management, and form
        participation — none of which survive when you swap in a generic element.
      </p>
      <p>
        Rebuilding that contract by hand is deceptively deep: a tabindex to make it focusable, a
        keydown handler that fires on Enter but waits for Space on keyup to match native behavior,
        aria-pressed where state applies, and a focus-visible ring that only shows for keyboard
        users. Miss one and it works for you but not for a screen reader.
      </p>
      <p>
        The rule that scales: start from the most semantic element that fits, and reach for ARIA
        only when the platform gives you nothing — a combobox, a tablist, a disclosure. ARIA is a
        promise you have to keep in code; semantic HTML keeps it for you.
      </p>
    </ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar
    class="pointer-events-none m-2 flex w-1 justify-center rounded-sm bg-gray-200 opacity-0 transition-opacity data-hovering:pointer-events-auto data-hovering:opacity-100 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-0"
  >
    <ScrollArea.Thumb class="w-full rounded-sm bg-gray-500" />
  </ScrollArea.Scrollbar>
</ScrollArea.Root>

Anatomy

<script>
  import { ScrollArea } from '@shardsui/svelte/scroll-area'
</script>

<ScrollArea.Root>
  <ScrollArea.Viewport>
    <ScrollArea.Content />
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar>
    <ScrollArea.Thumb />
  </ScrollArea.Scrollbar>
  <ScrollArea.Corner />
</ScrollArea.Root>

Examples

Both scrollbars

When both scrollbars are visible, add <ScrollArea.Corner> to fill the gap where they meet so they never overlap.

<script lang="ts">
  import { ScrollArea } from '@shardsui/svelte/scroll-area'
</script>

<ScrollArea.Root class="size-72 max-w-[calc(100vw-8rem)]">
  <ScrollArea.Viewport
    class="h-full rounded-lg border border-gray-200 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950"
  >
    <ScrollArea.Content class="p-4">
      <div
        class="size-160 rounded-md bg-[repeating-conic-gradient(var(--color-gray-300)_0_25%,var(--color-gray-100)_0_50%)] bg-size-[10rem_10rem]"
      ></div>
    </ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar
    class="pointer-events-none m-2 flex w-1 rounded-sm bg-gray-200 opacity-0 transition-opacity before:absolute before:left-1/2 before:h-full before:w-5 before:-translate-x-1/2 before:content-[''] data-hovering:pointer-events-auto data-hovering:opacity-100 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-0"
  >
    <ScrollArea.Thumb class="w-full rounded-sm bg-gray-500" />
  </ScrollArea.Scrollbar>
  <ScrollArea.Scrollbar
    orientation="horizontal"
    class="pointer-events-none m-2 flex h-1 rounded-sm bg-gray-200 opacity-0 transition-opacity before:absolute before:inset-x-0 before:-bottom-2 before:h-5 before:w-full before:content-[''] data-hovering:pointer-events-auto data-hovering:opacity-100 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-0"
  >
    <ScrollArea.Thumb class="h-full rounded-sm bg-gray-500" />
  </ScrollArea.Scrollbar>
  <ScrollArea.Corner />
</ScrollArea.Root>

Gradient scroll fade

Feed the viewport's overflow CSS variables into a mask-image to fade content near the edges. The fade deepens the further the user scrolls away from each edge.

.viewport {
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    black min(40px, var(--scroll-area-overflow-y-start)),
    black calc(100% - min(40px, var(--scroll-area-overflow-y-end, 40px))),
    transparent 100%
  );
  mask-repeat: no-repeat;
}

The variables are written on <ScrollArea.Viewport> and inherit from there, so the viewport itself or any element inside it can read them.

They are only set once the scroll area has measured itself after mount. Until then a var() without a fallback makes the whole mask-image invalid and nothing is masked, so give each call a fallback if the fade has to render on the first paint.

var(--scroll-area-overflow-y-start, 0px);
var(--scroll-area-overflow-y-end, 40px);
<script lang="ts">
  import { ScrollArea } from '@shardsui/svelte/scroll-area'

  const items = [
    'Semantic HTML',
    'The type scale',
    'Leading and tracking',
    'Flexbox',
    'Grid',
    'Focus states',
    'Contrast ratio',
    'Design tokens',
    'Easing and duration',
    'Final review'
  ]
</script>

<ScrollArea.Root
  class="h-48 w-80 max-w-[calc(100vw-8rem)] rounded-lg bg-gray-50 has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-gray-950"
>
  <ScrollArea.Viewport
    class="h-full rounded-md bg-gray-50 mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%-min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat outline-none"
  >
    <ScrollArea.Content class="py-3 pr-6 pl-4 text-sm/5.5 text-gray-900">
      <ol class="m-0 list-none p-0">
        {#each items as item, i (item)}
          <li class="flex items-center gap-3 py-1.5">
            <span class="w-6 text-right text-gray-400 tabular-nums">{i + 1}</span>
            <span>{item}</span>
          </li>
        {/each}
      </ol>
    </ScrollArea.Content>
  </ScrollArea.Viewport>
  <ScrollArea.Scrollbar
    class="pointer-events-none m-2 flex w-1 justify-center rounded-sm bg-gray-200 opacity-0 transition-opacity duration-150 before:absolute before:left-1/2 before:h-full before:w-5 before:-translate-x-1/2 before:content-[''] data-hovering:pointer-events-auto data-hovering:opacity-100 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-0"
  >
    <ScrollArea.Thumb class="w-full rounded-sm bg-gray-500" />
  </ScrollArea.Scrollbar>
</ScrollArea.Root>

Combining with Tabs

When a tab list overflows, wrap <Tabs.List> in a <ScrollArea.Viewport> so the list scrolls horizontally inside the scroll area. Nest the tabs as the viewport's content and add the scrollbar parts alongside it.

<Tabs.Root value="overview">
  <ScrollArea.Root>
    <ScrollArea.Viewport>
      <Tabs.List>
        <Tabs.Tab value="overview">Overview</Tabs.Tab>
        <Tabs.Indicator />
      </Tabs.List>
    </ScrollArea.Viewport>
    <ScrollArea.Scrollbar orientation="horizontal">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
  </ScrollArea.Root>
  <Tabs.Panel value="overview">...</Tabs.Panel>
</Tabs.Root>

Because the overflow variables inherit, the tab list can drive its own mask fade from --scroll-area-overflow-x-start and --scroll-area-overflow-x-end.

API reference

Root

Groups all parts of the scroll area. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-scrollingPresent while the user is scrolling the viewport.
data-has-overflow-xPresent when the content is wider than the viewport.
data-has-overflow-yPresent when the content is taller than the viewport.
data-overflow-x-startPresent when content is hidden past the horizontal start edge.
data-overflow-x-endPresent when content is hidden past the horizontal end edge.
data-overflow-y-startPresent when content is hidden past the vertical start edge.
data-overflow-y-endPresent when content is hidden past the vertical end edge.
CSS VariableDescription
--scroll-area-corner-widthWidth of the gap where the scrollbars meet; 0px unless both are visible.
--scroll-area-corner-heightHeight of the gap where the scrollbars meet; 0px unless both are visible.

Viewport

The element that scrolls. Hides the browser's scrollbars and carries the overflow CSS variables. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-scrollingPresent while the user is scrolling the viewport.
data-has-overflow-xPresent when the content is wider than the viewport.
data-has-overflow-yPresent when the content is taller than the viewport.
data-overflow-x-startPresent when content is hidden past the horizontal start edge.
data-overflow-x-endPresent when content is hidden past the horizontal end edge.
data-overflow-y-startPresent when content is hidden past the vertical start edge.
data-overflow-y-endPresent when content is hidden past the vertical end edge.
CSS VariableDescription
--scroll-area-overflow-x-startDistance scrolled from the horizontal start edge, in pixels.
--scroll-area-overflow-x-endDistance remaining to the horizontal end edge, in pixels.
--scroll-area-overflow-y-startDistance scrolled from the vertical start edge, in pixels.
--scroll-area-overflow-y-endDistance remaining to the vertical end edge, in pixels.

Content

A container for the content of the scroll area. Sized to fit its children so horizontal overflow measures correctly. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-scrollingPresent while the user is scrolling the viewport.
data-has-overflow-xPresent when the content is wider than the viewport.
data-has-overflow-yPresent when the content is taller than the viewport.
data-overflow-x-startPresent when content is hidden past the horizontal start edge.
data-overflow-x-endPresent when content is hidden past the horizontal end edge.
data-overflow-y-startPresent when content is hidden past the vertical start edge.
data-overflow-y-endPresent when content is hidden past the vertical end edge.

Scrollbar

A vertical or horizontal scrollbar for the scroll area. Rendered only while its axis overflows, unless keepMounted is set. Clicking the track jumps to that position; dragging the thumb scrolls. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-orientationIndicates the orientation of the scrollbar.
data-hoveringPresent while a mouse or pen is over the scroll area; touch never sets it.
data-scrollingPresent while the user scrolls along this scrollbar's axis.
data-has-overflow-xPresent when the content is wider than the viewport.
data-has-overflow-yPresent when the content is taller than the viewport.
data-overflow-x-startPresent when content is hidden past the horizontal start edge.
data-overflow-x-endPresent when content is hidden past the horizontal end edge.
data-overflow-y-startPresent when content is hidden past the vertical start edge.
data-overflow-y-endPresent when content is hidden past the vertical end edge.
CSS VariableDescription
--scroll-area-thumb-heightThe thumb's height; set on a vertical scrollbar.
--scroll-area-thumb-widthThe thumb's width; set on a horizontal scrollbar.

Thumb

The draggable part of the scrollbar that indicates the current scroll position. Sized from the scrollbar's thumb CSS variable, so it needs a cross-axis size of its own. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-orientationIndicates the orientation of the scrollbar.
data-scrollingPresent while the user scrolls along this scrollbar's axis.

Corner

Fills the gap where the horizontal and vertical scrollbars meet. Rendered only while both are visible. Renders a <div> element.

PropTypeDefault