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'

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.
  • 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.
  • 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.
  • 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.

Anatomy

Assemble the parts. One ScrollArea.Scrollbar per axis you want a bar on.

NameRequiredDescription
ScrollArea.Root true Renders a <div> and owns the metrics every other part reads.
ScrollArea.Viewport true The native scroller, with its own scrollbar hidden.
ScrollArea.Content true Wraps the content and reports its size back.
ScrollArea.ScrollbarOne per axis. Rendered while the area is hovered or moving.
ScrollArea.Thumb true Sized and positioned from the viewport metrics.
ScrollArea.CornerThe square where two scrollbars meet.
<script setup lang="ts">
import { ScrollArea } from '@maas/mirror/vue'
</script>

<template>
  <ScrollArea.Root>
    <ScrollArea.Viewport>
      <ScrollArea.Content>…</ScrollArea.Content>
    </ScrollArea.Viewport>
    <ScrollArea.Scrollbar orientation="vertical">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Scrollbar orientation="horizontal">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Corner />
  </ScrollArea.Root>
</template>

Examples

Showing the edges

Every part carries an attribute for each edge the content runs past, which is what a fade at the top or the bottom of a list styles against.

<template>
  <ScrollArea.Root class="fades">
    <ScrollArea.Viewport>
      <ScrollArea.Content>…</ScrollArea.Content>
    </ScrollArea.Viewport>
  </ScrollArea.Root>
</template>

<style>
.fades::before {
  opacity: 0;
  transition: opacity 150ms linear;
}

.fades[data-overflow-y-start='true']::before {
  opacity: 1;
}
</style>

options.overflowEdgeThreshold is how far the viewport has to be from an edge before it counts as away from it, in pixels. One number covers all four edges, an object covers the ones it names.

<template>
  <ScrollArea.Root :options="{ overflowEdgeThreshold: 16 }" />
  <ScrollArea.Root
    :options="{ overflowEdgeThreshold: { yStart: 16, yEnd: 24 } }"
  />
</template>

Keeping the scrollbars around

A scrollbar is mounted while the pointer is over the area or the content is moving, and leaves once options.hideDelay has passed. force-mount on ScrollArea.Scrollbar holds that scrollbar in the DOM instead, which a fade needs, and which keeps a scrollbar in place that should never come and go.

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

<style>
.scrollbar {
  opacity: 0;
  transition: opacity 150ms linear;
}

.scrollbar[data-hovering='true'],
.scrollbar[data-scrolling='true'] {
  opacity: 1;
}
</style>

Where the scrollbar should animate out rather than disappear, give it a transition and let the presence wrapper hold it until the animation ends.

<template>
  <ScrollArea.Root>
    <ScrollArea.Scrollbar transition="scrollbar" />
  </ScrollArea.Root>
</template>

The two axes rarely want the same treatment, so each scrollbar carries both keys itself.

<template>
  <ScrollArea.Root>
    <ScrollArea.Scrollbar force-mount orientation="vertical" />
    <ScrollArea.Scrollbar
      orientation="horizontal"
      transition="scrollbar-slide"
    />
  </ScrollArea.Root>
</template>

Where they should match, spread one object over both.

<script setup lang="ts">
const bar = { transition: 'scrollbar' }
</script>

<template>
  <ScrollArea.Root>
    <ScrollArea.Scrollbar v-bind="bar" orientation="vertical" />
    <ScrollArea.Scrollbar v-bind="bar" orientation="horizontal" />
  </ScrollArea.Root>
</template>

force-mount wins over transition, since a scrollbar that never leaves the DOM has nothing to transition. Fade a kept scrollbar from data-hovering and data-scrolling instead.

Both axes at once

Two scrollbars and a corner. Each scrollbar is rendered only for an axis that overflows, and both stop short of the corner on their own.

<template>
  <ScrollArea.Root>
    <ScrollArea.Viewport class="h-64">
      <ScrollArea.Content>…</ScrollArea.Content>
    </ScrollArea.Viewport>
    <ScrollArea.Scrollbar orientation="vertical">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Scrollbar orientation="horizontal">
      <ScrollArea.Thumb />
    </ScrollArea.Scrollbar>
    <ScrollArea.Corner />
  </ScrollArea.Root>
</template>

Reaching the scroll area from anywhere

Give the root an id and useMirrorScrollArea(id) reads the metrics and moves the viewport from anywhere in the app.

<template>
  <Button @click="scrollTo({ top: 0, behavior: 'smooth' })">Back to top</Button>
</template>

<script setup lang="ts">
const { scrollTo, hasOverflowY } = useMirrorScrollArea(ScrollAreaId.Releases)
</script>

API reference

Module. A bundled options object, and useMirrorScrollArea(id) as the programmatic API. Every part takes id to resolve a scroll area it is not nested inside.

ScrollArea.Root

Renders a <div> and owns the metrics, the hover state and the corner size. It carries a dir attribute wherever options.dir is set.

Props

PropTypeDefault
id
stringgenerated
options
ScrollAreaOptionssee below

Options

OptionTypeDefault
dir
'ltr' | 'rtl'inherited
overflowEdgeThreshold
number | { xStart, xEnd, yStart, yEnd }0
hideDelay
number500

Emits

EmitPayload
scrolling
boolean

Slot props

The overflow state, which every part publishes: hasOverflowX, hasOverflowY, overflowXStart, overflowXEnd, overflowYStart, overflowYEnd, cornerHidden and scrolling, all booleans.

ScrollArea.Viewport

Renders a <div> that scrolls natively, with its own scrollbar hidden rather than removed. It becomes a tab stop as soon as it overflows and holds nothing focusable of its own.

Slot props

The overflow state.

ScrollArea.Content

Renders a <div> around the content and reports its size, so a list that grows resizes the thumb with nothing else to call.

Slot props

The overflow state.

ScrollArea.Scrollbar

Renders a <div> positioned along its axis, inset by the corner where the two scrollbars would otherwise meet. A pointerdown on the bare track pages the viewport towards the pointer.

Props

PropTypeDefault
id
stringinjected
orientation
'horizontal' | 'vertical''vertical'
forceMount
booleanfalse
transition
stringundefined

Slot props

The overflow state, plus the three below.

PropType
orientation
'horizontal' | 'vertical'
hovering
boolean
dragging
boolean

ScrollArea.Thumb

Renders a <div> sized to the visible fraction of the content and positioned from the scroll offset. Dragging it captures the pointer and scrolls the viewport.

Slot props

The overflow state, plus orientation and dragging.

ScrollArea.Corner

Renders a <div> in the corner where the two scrollbars meet, as wide as the vertical scrollbar and as tall as the horizontal one. Both measurements are zero until both axes overflow, so it takes up no room in an area that scrolls one way.

Slot props

The overflow state.

Composable

useMirrorScrollArea(id) reaches a scroll area from anywhere in the app.

KeyType
viewportElement
ComputedRef<HTMLElement | null>
metrics
ComputedRef<ScrollAreaMetrics>
overflow
ComputedRef<ScrollAreaOverflowState>
hasOverflowX
ComputedRef<boolean>
hasOverflowY
ComputedRef<boolean>
scrolling
ComputedRef<boolean>
hovering
ComputedRef<boolean>
dragging
ComputedRef<'horizontal' | 'vertical' | null>
measure
() => void
scrollTo
(options: ScrollToOptions) => void

Data attributes

PartAttributeValue
all
data-has-overflow-x
true
all
data-has-overflow-y
true
all
data-overflow-x-start
true
all
data-overflow-x-end
true
all
data-overflow-y-start
true
all
data-overflow-y-end
true
all
data-scrolling
true
ScrollArea.Scrollbar, ScrollArea.Thumb
data-orientation
horizontal | vertical
ScrollArea.Scrollbar
data-hovering
true
ScrollArea.Scrollbar, ScrollArea.Thumb
data-dragging
true
ScrollArea.Scrollbar
data-state
open | closed

CSS variables

PartVariableDefault
ScrollArea.Thumb
--mirror-scroll-area-thumb-min-size
1rem
ScrollArea.Thumb
--mirror-scroll-area-thumb-cursor
default
ScrollArea.Scrollbar
--mirror-scroll-area-scrollbar-cursor
default

Errors

Code
missing_scroll_area_context
missing_scroll_area_scrollbar
duplicate_scrollbar_orientation

Accessibility

Scrolling is the browser’s, so every key below works on the focused viewport without the component listening for any of them. Where the content holds something focusable the viewport stays out of the tab order and the focused element scrolls it into view instead; where it holds nothing focusable the viewport becomes a tab stop of its own, which is why it needs an aria-label.

KeyBehaviour
ArrowUp / ArrowDownScroll by a line.
ArrowLeft / ArrowRightScroll sideways by a line.
PageUp / PageDownScroll by a viewport.
Home / EndJump to the top or the bottom.
Space / Shift + SpaceScroll down or up by a viewport.

The scrollbars are decoration: they carry no role and no tab stop, and everything they do with a pointer the keyboard already does through the viewport.