Skip to content

Scrollbar Utility

Discord-styled scrollbars for any scrollable container, with an optional keyboard-focusable viewport.

Discord-styled scrollbars for any scrollable container, with an optional keyboard-focusable viewport. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

Root class newt-scrollbar
import { ScrollArea } from "@/registry/bases/newt/ui/scrollbar"

const messages = Array.from({ length: 24 }, (_, i) => `Message line ${i + 1}`)

export default function ScrollbarDemo() {
  return (
    <ScrollArea className="max-h-[240px] w-full max-w-sm overflow-y-auto rounded-md bg-newt-bg-elevated p-3">
      <ul className="flex flex-col gap-2 text-[14px] text-newt-text-secondary">
        {messages.map((m) => (
          <li key={m}>{m}</li>
        ))}
      </ul>
    </ScrollArea>
  )
}
<script setup lang="ts">
import { ScrollArea } from "~~/registry/bases/newt/ui/scrollbar"

const messages = Array.from({ length: 24 }, (_, i) => `Message line ${i + 1}`)
</script>

<template>
  <ScrollArea
    class="max-h-[240px] w-full max-w-sm overflow-y-auto rounded-md bg-newt-bg-elevated p-3"
  >
    <ul class="flex flex-col gap-2 text-[14px] text-newt-text-secondary">
      <li v-for="m in messages" :key="m">{{ m }}</li>
    </ul>
  </ScrollArea>
</template>

Installation

pnpm dlx newtui@latest add scrollbar
npx newtui@latest add scrollbar
yarn dlx newtui@latest add scrollbar
bunx newtui@latest add scrollbar

Copy the source into your project

import * as React from "react"

import { cn } from "@/lib/utils"

/**
 * Tailwind classes that give any scrollable container Discord-styled
 * scrollbars (thin, rounded thumb, transparent track). Apply via
 * `<ScrollArea>` or spread `scrollbarClassName` on your own element.
 */
const scrollbarClassName = [
  "[scrollbar-width:thin] [scrollbar-color:var(--newt-bg-active)_transparent]",
  "[&::-webkit-scrollbar]:h-4 [&::-webkit-scrollbar]:w-4",
  "[&::-webkit-scrollbar-track]:bg-transparent",
  "[&::-webkit-scrollbar-thumb]:rounded-lg [&::-webkit-scrollbar-thumb]:border-4 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-newt-bg-active [&::-webkit-scrollbar-thumb]:bg-clip-padding",
  "[&::-webkit-scrollbar-thumb:hover]:bg-newt-text-muted [&::-webkit-scrollbar-thumb:hover]:bg-clip-padding",
  "[&::-webkit-scrollbar-corner]:bg-transparent",
].join(" ")

export interface ScrollAreaProps extends React.ComponentProps<"div"> {
  /**
   * Put the viewport in the tab order so it can be scrolled from the keyboard
   * (WCAG 2.1.1). Off by default: a focusable region only helps when the area
   * actually scrolls and holds no other focusable content.
   */
  focusable?: boolean
  /** Accessible name of the focusable viewport. */
  viewportLabel?: string
}

const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
  (
    {
      className,
      focusable = false,
      viewportLabel = "Scrollable region",
      ...props
    },
    ref
  ) => (
    <div
      ref={ref}
      tabIndex={focusable ? 0 : undefined}
      role={focusable ? "group" : undefined}
      aria-label={focusable ? viewportLabel : undefined}
      className={cn("overflow-auto", scrollbarClassName, className)}
      {...props}
    />
  )
)
ScrollArea.displayName = "ScrollArea"

export { ScrollArea, ScrollArea as Scrollbar, scrollbarClassName }
<script setup lang="ts">
import type { HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

import { scrollbarClassName } from "."

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    /**
     * Put the viewport in the tab order so it can be scrolled from the keyboard
     * (WCAG 2.1.1). Off by default: a focusable region only helps when the area
     * actually scrolls and holds no other focusable content.
     */
    focusable?: boolean
    /** Accessible name of the focusable viewport. */
    viewportLabel?: string
  }>(),
  { viewportLabel: "Scrollable region" }
)
</script>

<template>
  <div
    :tabindex="props.focusable ? 0 : undefined"
    :role="props.focusable ? 'group' : undefined"
    :aria-label="props.focusable ? props.viewportLabel : undefined"
    :class="cn('overflow-auto', scrollbarClassName, props.class)"
  >
    <slot />
  </div>
</template>
export { default as ScrollArea } from "./ScrollArea.vue"

/**
 * Tailwind classes that give any scrollable container Discord-styled
 * scrollbars (thin, rounded thumb, transparent track).
 */
export const scrollbarClassName = [
  "[scrollbar-width:thin] [scrollbar-color:var(--newt-bg-active)_transparent]",
  "[&::-webkit-scrollbar]:h-4 [&::-webkit-scrollbar]:w-4",
  "[&::-webkit-scrollbar-track]:bg-transparent",
  "[&::-webkit-scrollbar-thumb]:rounded-lg [&::-webkit-scrollbar-thumb]:border-4 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-newt-bg-active [&::-webkit-scrollbar-thumb]:bg-clip-padding",
  "[&::-webkit-scrollbar-thumb:hover]:bg-newt-text-muted [&::-webkit-scrollbar-thumb:hover]:bg-clip-padding",
  "[&::-webkit-scrollbar-corner]:bg-transparent",
].join(" ")

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { Scrollbar } from "@/components/ui/scrollbar"

export function Example() {
  return <Scrollbar />
}
<script setup lang="ts">
import { ScrollArea } from "@/components/ui/scrollbar"
</script>

<template>
  <ScrollArea />
</template>

API reference

ScrollArea

PropType
focusable?boolean | undefined

Put the viewport in the tab order so it can be scrolled from the keyboard (WCAG 2.1.1). Off by default: a focusable region only helps when the area actually scrolls and holds no other focusable content.

Typeboolean | undefined
viewportLabel?string | undefined

Accessible name of the focusable viewport.

Typestring | undefined

The registry exports ScrollArea, Scrollbar. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Scrollbar Utility reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-scrollbar.