ScrollArea
ScrollArea gives a fixed-size region custom scrollbars that look the same on every platform. Use it for panels, lists and drawers with overflowing content. For a full page, let the browser scroll the page.
Release notes
Scroll areas render a consistent custom scrollbar across Chrome, Safari, and Firefox, replacing the platform scrollbar so the chrome looks the same everywhere.
The viewport measures its content and only surfaces a scrollbar on the axis that actually overflows — a wide-but-short block gets a horizontal bar and no vertical one.
Scrollbars fade in on hover by default. Pass type="always" to keep them pinned, or type="scroll" to show them only while the content is moving.
The thumb is sized in proportion to the visible fraction of the content, so a long document gets a short thumb and a barely-overflowing one gets a long thumb.
Keyboard focus can move into the viewport with a visible focus ring, so the scrollable region stays reachable without a pointer.
'use client';
import { ScrollArea } from '@/components/nxds/components/scroll-area/scroll-area';
const releaseNotes = [
'Scroll areas render a consistent custom scrollbar across Chrome, Safari, and Firefox, replacing the platform scrollbar so the chrome looks the same everywhere.',
'The viewport measures its content and only surfaces a scrollbar on the axis that actually overflows — a wide-but-short block gets a horizontal bar and no vertical one.',
'Scrollbars fade in on hover by default. Pass type="always" to keep them pinned, or type="scroll" to show them only while the content is moving.',
'The thumb is sized in proportion to the visible fraction of the content, so a long document gets a short thumb and a barely-overflowing one gets a long thumb.',
'Keyboard focus can move into the viewport with a visible focus ring, so the scrollable region stays reachable without a pointer.',
];
export default function ScrollAreaDefault() {
return (
<ScrollArea className="nx:h-48 nx:w-72 nx:rounded-md nx:border-default nx:border-border-default">
<div className="nx:flex nx:flex-col nx:gap-3 nx:p-4 nx:typography-label-default nx:text-foreground">
<h4 className="nx:font-medium nx:leading-none">Release notes</h4>
{releaseNotes.map((note, i) => (
<p key={i} className="nx:text-muted-foreground">
{note}
</p>
))}
</div>
</ScrollArea>
);
}
Installation
npx nxds-cli@latest add scroll-area
Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.
Usage
import { ScrollArea, ScrollBar } from '@/components/nxds/components/scroll-area/scroll-area';
Composition
ScrollArea
└── ScrollBar
Examples
Vertical list
Scroll a long list of rows inside a fixed height.
Builds
- v1.4.0-canary.40
- v1.4.0-canary.39
- v1.4.0-canary.38
- v1.4.0-canary.37
- v1.4.0-canary.36
- v1.4.0-canary.35
- v1.4.0-canary.34
- v1.4.0-canary.33
- v1.4.0-canary.32
- v1.4.0-canary.31
- v1.4.0-canary.30
- v1.4.0-canary.29
- v1.4.0-canary.28
- v1.4.0-canary.27
- v1.4.0-canary.26
- v1.4.0-canary.25
- v1.4.0-canary.24
- v1.4.0-canary.23
- v1.4.0-canary.22
- v1.4.0-canary.21
- v1.4.0-canary.20
- v1.4.0-canary.19
- v1.4.0-canary.18
- v1.4.0-canary.17
- v1.4.0-canary.16
- v1.4.0-canary.15
- v1.4.0-canary.14
- v1.4.0-canary.13
- v1.4.0-canary.12
- v1.4.0-canary.11
- v1.4.0-canary.10
- v1.4.0-canary.9
- v1.4.0-canary.8
- v1.4.0-canary.7
- v1.4.0-canary.6
- v1.4.0-canary.5
- v1.4.0-canary.4
- v1.4.0-canary.3
- v1.4.0-canary.2
- v1.4.0-canary.1
'use client';
import { ScrollArea } from '@/components/nxds/components/scroll-area/scroll-area';
const versions = Array.from(
{ length: 40 },
(_, i) => `v1.4.0-canary.${40 - i}`
);
export default function ScrollAreaVerticalList() {
return (
<ScrollArea className="nx:h-72 nx:w-56 nx:rounded-md nx:border-default nx:border-border-default">
<div className="nx:p-4">
<h4 className="nx:mb-3 nx:typography-label-default nx:leading-none nx:text-foreground">
Builds
</h4>
<ul className="nx:flex nx:flex-col">
{versions.map((version) => (
<li
key={version}
className="nx:border-b-default nx:border-border-default nx:py-2 nx:typography-label-default nx:text-foreground nx:last:border-b-0"
>
{version}
</li>
))}
</ul>
</div>
</ScrollArea>
);
}
Horizontal row
Add a horizontal ScrollBar to scroll a row of items sideways.
'use client';
import { ScrollArea, ScrollBar } from '@/components/nxds/components/scroll-area/scroll-area';
const artworks = [
{ title: 'Aurora', artist: 'Ornella Binni' },
{ title: 'Tide', artist: 'Tom Byrom' },
{ title: 'Drift', artist: 'Vladimir Malyavko' },
{ title: 'Ember', artist: 'Mara Stein' },
{ title: 'Quartz', artist: 'Liang Wu' },
{ title: 'Meadow', artist: 'Sofia Russo' },
];
export default function ScrollAreaHorizontalRow() {
return (
<ScrollArea className="nx:w-96 nx:rounded-md nx:border-default nx:border-border-default nx:whitespace-nowrap">
<div className="nx:flex nx:w-max nx:gap-4 nx:p-4">
{artworks.map((artwork) => (
<figure key={artwork.title} className="nx:shrink-0">
<div className="nx:flex nx:h-40 nx:w-32 nx:items-end nx:rounded-md nx:bg-muted nx:p-3">
<span className="nx:typography-label-default nx:text-foreground">
{artwork.title}
</span>
</div>
<figcaption className="nx:pt-2 nx:typography-label-small nx:text-muted-foreground">
by {artwork.artist}
</figcaption>
</figure>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}
Both
Scroll a large grid on both axes with two scrollbars.
'use client';
import { ScrollArea, ScrollBar } from '@/components/nxds/components/scroll-area/scroll-area';
export default function ScrollAreaBoth() {
return (
<ScrollArea className="nx:h-72 nx:w-96 nx:rounded-md nx:border-default nx:border-border-default">
<div className="nx:w-max nx:p-4">
{Array.from({ length: 20 }, (_, row) => (
<div key={row} className="nx:flex nx:gap-2 nx:py-1">
{Array.from({ length: 15 }, (_, col) => (
<div
key={col}
className="nx:flex nx:size-12 nx:shrink-0 nx:items-center nx:justify-center nx:rounded-sm nx:bg-muted nx:typography-label-small nx:text-muted-foreground"
>
{row * 15 + col}
</div>
))}
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}
Visible affordance
Set type="always" so dense panels show their scrollbars without a hover.
'use client';
import { ScrollArea, ScrollBar } from '@/components/nxds/components/scroll-area/scroll-area';
const jobRuns = Array.from({ length: 28 }, (_, i) => ({
id: `run-${4820 - i}`,
branch: ['main', 'release/vega', 'codex/ui-polish', 'docs/mwg'][i % 4],
owner: ['Mira', 'Omar', 'Jules', 'Nia'][i % 4],
duration: `${3 + (i % 9)}m ${10 + ((i * 7) % 50)}s`,
status: ['Passed', 'Queued', 'Reviewing', 'Blocked'][i % 4],
}));
export default function ScrollAreaVisibleAffordance() {
return (
<ScrollArea
type="always"
className="nx:h-64 nx:w-80 nx:rounded-md nx:border-default nx:border-border-default"
>
<div className="nx:w-max nx:p-4">
<div className="nx:grid nx:grid-cols-[112px_160px_96px_96px_96px] nx:gap-x-4 nx:border-b-default nx:border-border-default nx:pb-2 nx:typography-label-small nx:text-muted-foreground">
<span>Run</span>
<span>Branch</span>
<span>Owner</span>
<span>Duration</span>
<span>Status</span>
</div>
<div className="nx:flex nx:flex-col">
{jobRuns.map((run) => (
<div
key={run.id}
className="nx:grid nx:grid-cols-[112px_160px_96px_96px_96px] nx:gap-x-4 nx:border-b-default nx:border-border-default nx:py-2 nx:typography-label-default nx:text-foreground nx:last:border-b-0"
>
<span className="nx:font-mono nx:typography-label-small">
{run.id}
</span>
<span>{run.branch}</span>
<span>{run.owner}</span>
<span>{run.duration}</span>
<span>{run.status}</span>
</div>
))}
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}
In card
Keep a long list inside a Card from stretching the Card.
Builds
- v1.4.0-canary.40
- v1.4.0-canary.39
- v1.4.0-canary.38
- v1.4.0-canary.37
- v1.4.0-canary.36
- v1.4.0-canary.35
- v1.4.0-canary.34
- v1.4.0-canary.33
- v1.4.0-canary.32
- v1.4.0-canary.31
- v1.4.0-canary.30
- v1.4.0-canary.29
- v1.4.0-canary.28
- v1.4.0-canary.27
- v1.4.0-canary.26
- v1.4.0-canary.25
- v1.4.0-canary.24
- v1.4.0-canary.23
- v1.4.0-canary.22
- v1.4.0-canary.21
- v1.4.0-canary.20
- v1.4.0-canary.19
- v1.4.0-canary.18
- v1.4.0-canary.17
- v1.4.0-canary.16
- v1.4.0-canary.15
- v1.4.0-canary.14
- v1.4.0-canary.13
- v1.4.0-canary.12
- v1.4.0-canary.11
- v1.4.0-canary.10
- v1.4.0-canary.9
- v1.4.0-canary.8
- v1.4.0-canary.7
- v1.4.0-canary.6
- v1.4.0-canary.5
- v1.4.0-canary.4
- v1.4.0-canary.3
- v1.4.0-canary.2
- v1.4.0-canary.1
'use client';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/nxds/components/card/card';
import { ScrollArea } from '@/components/nxds/components/scroll-area/scroll-area';
const versions = Array.from(
{ length: 40 },
(_, i) => `v1.4.0-canary.${40 - i}`
);
export default function ScrollAreaInCard() {
return (
<Card className="nx:w-80">
<CardHeader>
<CardTitle>Builds</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="nx:h-48 nx:rounded-md nx:border-default nx:border-border-default">
<ul className="nx:flex nx:flex-col nx:p-3">
{versions.map((version) => (
<li
key={version}
className="nx:border-b-default nx:border-border-default nx:py-2 nx:typography-label-default nx:text-container-foreground nx:last:border-b-0"
>
{version}
</li>
))}
</ul>
</ScrollArea>
</CardContent>
</Card>
);
}
This example also needs: Card.
Props
ScrollArea
ScrollArea adds no props of its own; it accepts the props of the element or primitive it wraps.
ScrollBar
ScrollBar adds no props of its own; it accepts the props of the element or primitive it wraps.
ScrollArea gives a fixed-size region custom scrollbars that look the same on every platform. Use it for panels, lists and drawers with overflowing content. For a full page, let the browser scroll the page.
<script setup lang="ts">
import { ScrollArea } from '@/components/scroll-area';
const notes = [
'Scroll areas render a consistent custom scrollbar across Chrome, Safari, and Firefox, replacing the platform scrollbar so the chrome looks the same everywhere.',
'The viewport measures its content and only surfaces a scrollbar on the axis that actually overflows — a wide-but-short block gets a horizontal bar and no vertical one.',
'Scrollbars fade in on hover by default. Pass type="always" to keep them pinned, or type="scroll" to show them only while the content is moving.',
'The thumb is sized in proportion to the visible fraction of the content, so a long document gets a short thumb and a barely-overflowing one gets a long thumb.',
'Keyboard focus can move into the viewport with a visible focus ring, so the scrollable region stays reachable without a pointer.',
];
</script>
<template>
<ScrollArea
class="nx:h-48 nx:w-72 nx:rounded-md nx:border-default nx:border-border-default"
>
<div
class="nx:flex nx:flex-col nx:gap-3 nx:p-4 nx:typography-label-default nx:text-foreground"
>
<h4 class="nx:font-medium nx:leading-none">Release notes</h4>
<p v-for="(note, i) in notes" :key="i" class="nx:text-muted-foreground">
{{ note }}
</p>
</div>
</ScrollArea>
</template>
Installation
- 1
Install the following dependencies:
npm install @vueuse/core@^14.1.0 class-variance-authority@^0.7.1 reka-ui@^2.11.0 - 2
Copy and paste the following code into your project.
components/scroll-area/ScrollArea.vue <script setup lang="ts"> import { reactiveOmit } from '@vueuse/core'; import { ScrollAreaCorner, ScrollAreaRoot, type ScrollAreaRootProps, ScrollAreaViewport, } from 'reka-ui'; import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; import ScrollBar from './ScrollBar.vue'; export interface ScrollAreaProps extends ScrollAreaRootProps { class?: HTMLAttributes['class']; } const props = defineProps<ScrollAreaProps>(); const delegated = reactiveOmit(props, 'class'); </script> <template> <ScrollAreaRoot v-bind="delegated" data-slot="scroll-area" :class="cn('nx:relative', props.class)" > <ScrollAreaViewport data-slot="scroll-area-viewport" tabindex="0" class="nx:size-full nx:rounded-[inherit] nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default" > <slot /> </ScrollAreaViewport> <ScrollBar /> <ScrollAreaCorner data-slot="scroll-area-corner" /> </ScrollAreaRoot> </template>components/scroll-area/ScrollBar.vue <script setup lang="ts"> import { reactiveOmit } from '@vueuse/core'; import { ScrollAreaScrollbar, type ScrollAreaScrollbarProps, ScrollAreaThumb, } from 'reka-ui'; import type { HTMLAttributes } from 'vue'; import { cn } from '../../lib/utils'; import { scrollBarVariants } from '.'; export interface ScrollBarProps extends ScrollAreaScrollbarProps { class?: HTMLAttributes['class']; } const props = withDefaults(defineProps<ScrollBarProps>(), { orientation: 'vertical', }); const delegated = reactiveOmit(props, 'class'); </script> <template> <ScrollAreaScrollbar v-bind="delegated" data-slot="scroll-bar" :class="cn(scrollBarVariants({ orientation }), props.class)" > <ScrollAreaThumb data-slot="scroll-bar-thumb" class="nx:relative nx:flex-1 nx:rounded-full nx:bg-border-default" /> </ScrollAreaScrollbar> </template>components/scroll-area/index.ts import { cva } from 'class-variance-authority'; export { default as ScrollArea, type ScrollAreaProps } from './ScrollArea.vue'; export { default as ScrollBar, type ScrollBarProps } from './ScrollBar.vue'; export const scrollBarVariants = cva( 'nx:flex nx:touch-none nx:select-none nx:p-px nx:transition-colors', { variants: { orientation: { vertical: 'nx:h-full nx:w-2.5 nx:border-l-default nx:border-l-transparent', horizontal: 'nx:h-2.5 nx:flex-col nx:border-t-default nx:border-t-transparent', }, }, defaultVariants: { orientation: 'vertical', }, } ); - 3
Keep each file at the path shown above it.
Paths are relative to
packages/vue/src/. Keep the same folders under your@/root, and the imports between files work without edits.
Usage
import { ScrollArea, ScrollBar } from '@/components/scroll-area';
Examples
Vertical list
Scroll a long list of rows inside a fixed height.
<script setup lang="ts">
import { ScrollArea } from '@/components/scroll-area';
const versions = Array.from(
{ length: 40 },
(_, i) => `v1.4.0-canary.${40 - i}`
);
</script>
<template>
<ScrollArea
class="nx:h-72 nx:w-56 nx:rounded-md nx:border-default nx:border-border-default"
>
<div class="nx:p-4">
<h4
class="nx:mb-3 nx:typography-label-default nx:leading-none nx:text-foreground"
>
Builds
</h4>
<ul class="nx:flex nx:flex-col">
<li
v-for="version in versions"
:key="version"
class="nx:border-b-default nx:border-border-default nx:py-2 nx:typography-label-default nx:text-foreground nx:last:border-b-0"
>
{{ version }}
</li>
</ul>
</div>
</ScrollArea>
</template>
Horizontal row
Add a horizontal ScrollBar to scroll a row of items sideways.
<script setup lang="ts">
import { ScrollArea, ScrollBar } from '@/components/scroll-area';
const artworks = [
{ title: 'Aurora', artist: 'Ornella Binni' },
{ title: 'Tide', artist: 'Tom Byrom' },
{ title: 'Drift', artist: 'Vladimir Malyavko' },
{ title: 'Ember', artist: 'Mara Stein' },
{ title: 'Quartz', artist: 'Liang Wu' },
{ title: 'Meadow', artist: 'Sofia Russo' },
];
</script>
<template>
<ScrollArea
class="nx:w-96 nx:rounded-md nx:border-default nx:border-border-default nx:whitespace-nowrap"
>
<div class="nx:flex nx:w-max nx:gap-4 nx:p-4">
<figure
v-for="artwork in artworks"
:key="artwork.title"
class="nx:shrink-0"
>
<div
class="nx:flex nx:h-40 nx:w-32 nx:items-end nx:rounded-md nx:bg-muted nx:p-3"
>
<span class="nx:typography-label-default nx:text-foreground">{{
artwork.title
}}</span>
</div>
<figcaption
class="nx:pt-2 nx:typography-label-small nx:text-muted-foreground"
>
by {{ artwork.artist }}
</figcaption>
</figure>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</template>
Both
Scroll a large grid on both axes with two scrollbars.
<script setup lang="ts">
import { ScrollArea, ScrollBar } from '@/components/scroll-area';
</script>
<template>
<ScrollArea
class="nx:h-72 nx:w-96 nx:rounded-md nx:border-default nx:border-border-default"
>
<div class="nx:w-max nx:p-4">
<div v-for="row in 20" :key="row" class="nx:flex nx:gap-2 nx:py-1">
<div
v-for="col in 15"
:key="col"
class="nx:flex nx:size-12 nx:shrink-0 nx:items-center nx:justify-center nx:rounded-sm nx:bg-muted nx:typography-label-small nx:text-muted-foreground"
>
{{ (row - 1) * 15 + col - 1 }}
</div>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</template>
Visible affordance
Set type="always" so dense panels show their scrollbars without a hover.
<script setup lang="ts">
import { ScrollArea, ScrollBar } from '@/components/scroll-area';
const runs = Array.from({ length: 28 }, (_, i) => ({
id: `run-${4820 - i}`,
branch: ['main', 'release/vega', 'codex/ui-polish', 'docs/mwg'][i % 4],
owner: ['Mira', 'Omar', 'Jules', 'Nia'][i % 4],
duration: `${3 + (i % 9)}m ${10 + ((i * 7) % 50)}s`,
status: ['Passed', 'Queued', 'Reviewing', 'Blocked'][i % 4],
}));
</script>
<template>
<ScrollArea
type="always"
class="nx:h-64 nx:w-80 nx:rounded-md nx:border-default nx:border-border-default"
>
<div class="nx:w-max nx:p-4">
<div
class="nx:grid nx:grid-cols-[112px_160px_96px_96px_96px] nx:gap-x-4 nx:border-b-default nx:border-border-default nx:pb-2 nx:typography-label-small nx:text-muted-foreground"
>
<span>Run</span>
<span>Branch</span>
<span>Owner</span>
<span>Duration</span>
<span>Status</span>
</div>
<div class="nx:flex nx:flex-col">
<div
v-for="run in runs"
:key="run.id"
class="nx:grid nx:grid-cols-[112px_160px_96px_96px_96px] nx:gap-x-4 nx:border-b-default nx:border-border-default nx:py-2 nx:typography-label-default nx:text-foreground nx:last:border-b-0"
>
<span class="nx:font-mono nx:typography-label-small">{{
run.id
}}</span>
<span>{{ run.branch }}</span>
<span>{{ run.owner }}</span>
<span>{{ run.duration }}</span>
<span>{{ run.status }}</span>
</div>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</template>
In card
Keep a long list inside a Card from stretching the Card.
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/card';
import { ScrollArea } from '@/components/scroll-area';
const versions = Array.from(
{ length: 40 },
(_, i) => `v1.4.0-canary.${40 - i}`
);
</script>
<template>
<Card class="nx:w-80">
<CardHeader>
<CardTitle>Builds</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea
class="nx:h-48 nx:rounded-md nx:border-default nx:border-border-default"
>
<ul class="nx:flex nx:flex-col nx:p-3">
<li
v-for="version in versions"
:key="version"
class="nx:border-b-default nx:border-border-default nx:py-2 nx:typography-label-default nx:text-container-foreground nx:last:border-b-0"
>
{{ version }}
</li>
</ul>
</ScrollArea>
</CardContent>
</Card>
</template>
This example also needs: Card.
Props
ScrollArea
ScrollArea adds no props of its own; it accepts the props of the element or primitive it wraps.
ScrollBar
ScrollBar adds no props of its own; it accepts the props of the element or primitive it wraps.