Skip to contentVibraUI
Utilities

Scroll area

A region that scrolls inside a fixed height, with a thin overlay scrollbar.

shadcn's base-nova scroll area — Base UI's viewport with a thin thumb drawn in --border — with two changes. The scroller takes the name: aria-label or aria-labelledby given to the scroll area lands on the viewport, as a region, while its content overflows. That is the only time Base UI puts the viewport in the tab order, so a keyboard reader who lands on it hears what it scrolls; with nothing to scroll it is neither named nor focusable. And its focus treatment is the kit's 2px accent outline, where shadcn's is a half-strength halo that falls under 3:1 on the card under the coloured palettes — drawn on the root, inside its edge, while the viewport has keyboard focus, so a mask on the viewport cannot clip it away and an ancestor that clips its overflow cannot cut it. When everything inside is focusable already — a list of links or buttons, which scrolls itself into view as each takes the focus — pass focusableViewport={false}, and the viewport is neither a tab stop nor a region. Give the root a height, or a max-height on the root and the viewport, and it scrolls what is inside; add a horizontal ScrollBar for content that scrolls sideways. Base UI keeps how far the viewport is from each edge in --scroll-area-overflow-{x,y}-{start,end}, which a mask can read to fade the edges.

Install

npx shadcn@latest add @vibra/scroll-area

Needs the @vibra registry in your components.json — set it up once.

Examples

Sideways

Saved views as chips on one line with a horizontal bar, keeping room for each chip's focus ring at the scroller's edge.

Both ways

A wide table in one scroller with both bars and the corner, its titles stuck to the top; the caption names the region.

Grouped, with sticky headings

Payments by week, each week's heading holding at the top of the scroller until the next one pushes it off.

Fading edges

A mask fades the edges where there is more to read, from Base UI's overflow variables; nothing is hidden from a screen reader.

In a popover

A team list scrolling inside the panel is a named region in the tab order only while it overflows; filter it to fit and it is neither.

Props

PropTypeDefaultDescription
aria-label / aria-labelledbystring—Names the scroller. It goes to the viewport, as a region, while the content overflows — which is when the viewport is in the tab order.
focusableViewportbooleantrueWhether the viewport is a tab stop, and a named region, while it overflows. Turn it off when everything inside is focusable already.
ScrollBar.orientation"vertical" | "horizontal""vertical"The vertical bar comes with the scroll area; put a horizontal one inside it for content that scrolls sideways.

Dependencies

Source

components/ui/scroll-area.tsx
"use client"

import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"

function ScrollArea({
  className,
  children,
  "aria-label": label,
  "aria-labelledby": labelledBy,
  focusableViewport = true,
  ...props
}: ScrollAreaPrimitive.Root.Props & {
  /**
   * Whether the viewport is a tab stop while it overflows, as Base UI makes
   * it. Turn it off when everything inside is focusable already — a list of
   * links or buttons, whose own focus scrolls it into view — so the viewport
   * is not one more stop in front of them, and names nothing.
   */
  focusableViewport?: boolean
}) {
  const named = focusableViewport && (label !== undefined || labelledBy !== undefined)

  return (
    <ScrollAreaPrimitive.Root
      data-slot="scroll-area"
      // The focus outline is drawn on the root, inside its edge: a mask on the
      // viewport (the fades pattern) clipped the viewport's own outline away
      // entirely, and an ancestor that clips its overflow — a popover, a card
      // — cannot cut an inset one.
      className={cn("relative has-[>[data-slot=scroll-area-viewport]:focus-visible]:focus-outline-inset", className)}
      {...props}
    >
      <ScrollAreaPrimitive.Viewport
        data-slot="scroll-area-viewport"
        className="size-full rounded-[inherit] outline-none"
        // Base UI puts the viewport in the tab order only while its content
        // overflows, and that is when a keyboard reader lands on it — so that
        // is when it becomes a region carrying the scroll area's name. A name
        // on the root sat on a box nobody can focus.
        render={(viewportProps, state) => (
          <div
            {...viewportProps}
            {...(focusableViewport ? null : { tabIndex: -1 })}
            {...(named && (state.hasOverflowX || state.hasOverflowY)
              ? { role: "region", "aria-label": label, "aria-labelledby": labelledBy }
              : null)}
          />
        )}
      >
        {children}
      </ScrollAreaPrimitive.Viewport>
      <ScrollBar />
      <ScrollAreaPrimitive.Corner />
    </ScrollAreaPrimitive.Root>
  )
}

function ScrollBar({
  className,
  orientation = "vertical",
  ...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
  return (
    <ScrollAreaPrimitive.Scrollbar
      data-slot="scroll-area-scrollbar"
      data-orientation={orientation}
      orientation={orientation}
      className={cn(
        "flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-s data-vertical:border-s-transparent",
        className
      )}
      {...props}
    >
      <ScrollAreaPrimitive.Thumb
        data-slot="scroll-area-thumb"
        className="relative flex-1 rounded-full bg-border"
      />
    </ScrollAreaPrimitive.Scrollbar>
  )
}

export { ScrollArea, ScrollBar }