Skip to content

Scroll Area

A native scroller with the browser scrollbar swapped for one you style yourself.

View source View as Markdown

Scroll Area keeps the browser’s own scrolling and hides only the scrollbar it draws, so you can put your own in its place. The wheel, the trackpad, the keyboard and the touch fling all stay native, and the bar becomes a few elements you style like everything else.

2.0.0-beta.20
2.0.0-beta.19
2.0.0-beta.18
2.0.0-beta.17
2.0.0-beta.16
2.0.0-beta.15
2.0.0-beta.14
2.0.0-beta.13
2.0.0-beta.12
2.0.0-beta.11
2.0.0-beta.10
2.0.0-beta.9
2.0.0-beta.8
2.0.0-beta.7
2.0.0-beta.6
2.0.0-beta.5
2.0.0-beta.4
2.0.0-beta.3
2.0.0-beta.2
2.0.0-beta.1
headlessunstyledaccessiblecomposabletypedthemeable
App.vue
<template>
  <div class="flex w-72 flex-col gap-6">
    <ScrollArea.Root :class="root">
      <ScrollArea.Viewport :class="[viewport, 'h-48']" aria-label="Releases">
        <ScrollArea.Content class="flex flex-col gap-1 pr-3">
          <div v-for="entry in releases" :key="entry" :class="row">
            {{ entry }}
          </div>
        </ScrollArea.Content>
      </ScrollArea.Viewport>

      <ScrollArea.Scrollbar :class="scrollbar" force-mount>
        <ScrollArea.Thumb :class="thumb" />
      </ScrollArea.Scrollbar>
    </ScrollArea.Root>

    <ScrollArea.Root :class="root">
      <ScrollArea.Viewport :class="viewport" aria-label="Tags">
        <ScrollArea.Content class="flex gap-1 pb-3">
          <span v-for="tag in tags" :key="tag" :class="chip">{{ tag }}</span>
        </ScrollArea.Content>
      </ScrollArea.Viewport>

      <ScrollArea.Scrollbar
        :class="scrollbar"
        force-mount
        orientation="horizontal"
      >
        <ScrollArea.Thumb :class="thumb" />
      </ScrollArea.Scrollbar>
    </ScrollArea.Root>
  </div>
</template>

<script setup lang="ts">
import { ScrollArea } from '@maas/mirror/vue/ScrollArea'

const releases = Array.from({ length: 20 }, (_, index) => {
  return `2.0.0-beta.${20 - index}`
})

const tags = [
  'headless',
  'unstyled',
  'accessible',
  'composable',
  'typed',
  'themeable',
]

const root = 'rounded-component-md border-surface border p-1.5'

const viewport = [
  'w-full',
  'rounded-component-compact-md outline-4 outline-transparent',
  'focus-visible:focus-ring',
].join(' ')

const row = [
  'rounded-component-compact-md bg-surface-higher text-surface-muted',
  'type-component-sm -number px-3 py-2',
].join(' ')

const chip = [
  'rounded-component-compact-md bg-surface-higher text-surface-muted',
  'type-component-sm px-3 py-1.5 whitespace-nowrap',
].join(' ')

const scrollbar = [
  'w-2 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-auto',
  'opacity-0 transition-opacity duration-150 ease-linear',
  'data-[hovering=true]:opacity-100 data-[scrolling=true]:opacity-100',
].join(' ')

const thumb = 'bg-primary-muted rounded-full'
</script>

Usage guidelines

  • Give the viewport a size, either directly or through the root, since it fills the root and the root sizes itself from whatever you put around it.must
  • Leave room for the scrollbar with padding on the content rather than a margin on the viewport, or the overlay sits on top of the last few pixels of text.should
  • The thumb is sized as a fraction of the track, so a taller viewport gets a taller thumb; use --mirror-scroll-area-thumb-min-size for the point where very long content would otherwise leave nothing to grab.may
  • Give the viewport an aria-label when it holds no focusable elements, since it becomes a tab stop of its own and a screen reader announces it.must