Scroll Area
A native scroller with the browser scrollbar swapped for one you style yourself.
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.
<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-sizefor the point where very long content would otherwise leave nothing to grab. - Give the viewport an
aria-labelwhen 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.
| Name | Required | Description |
|---|---|---|
| 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.Scrollbar | One per axis. Rendered while the area is hovered or moving. | |
| ScrollArea.Thumb | true | Sized and positioned from the viewport metrics. |
| ScrollArea.Corner | The 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
Options
Emits
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
Slot props
The overflow state, plus the three below.
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.
Data attributes
CSS variables
Errors
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.
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.