FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Scroll Area

Scroll Area

pnpm dlx shadcn@latest add …/r/scroll-area.json

An accessible React scroll area for Next.js and Tailwind CSS: native scrolling with a slim scrollbar and soft fades at the edges, built on Radix.

  • Tags

    v1.2.0-beta.40

    v1.2.0-beta.39

    v1.2.0-beta.38

    v1.2.0-beta.37

    v1.2.0-beta.36

    v1.2.0-beta.35

    v1.2.0-beta.34

    v1.2.0-beta.33

    v1.2.0-beta.32

    v1.2.0-beta.31

    v1.2.0-beta.30

    v1.2.0-beta.29

    v1.2.0-beta.28

    v1.2.0-beta.27

    v1.2.0-beta.26

    v1.2.0-beta.25

    v1.2.0-beta.24

    v1.2.0-beta.23

    v1.2.0-beta.22

    v1.2.0-beta.21

    v1.2.0-beta.20

    v1.2.0-beta.19

    v1.2.0-beta.18

    v1.2.0-beta.17

    v1.2.0-beta.16

    v1.2.0-beta.15

    v1.2.0-beta.14

    v1.2.0-beta.13

    v1.2.0-beta.12

    v1.2.0-beta.11

    v1.2.0-beta.10

    v1.2.0-beta.9

    v1.2.0-beta.8

    v1.2.0-beta.7

    v1.2.0-beta.6

    v1.2.0-beta.5

    v1.2.0-beta.4

    v1.2.0-beta.3

    v1.2.0-beta.2

    v1.2.0-beta.1

    Vertical
    • Q3 report.pdf2.4 MB
    • Roadmap.fig18 MB
    • Invoices.csv84 KB
    • Brand guide.pdf9.1 MB
    • Logo.svg6 KB
    • Launch notes.md3 KB
    • Pitch.key42 MB
    Horizontal
import { ScrollArea } from "@/components/ui/scroll-area"

export function Tags({ tags }: { tags: string[] }) {
  return (
    <ScrollArea aria-label="Tags" className="h-72 w-48 rounded-lg">
      <div className="p-4">
        {tags.map((tag) => (
          <div key={tag} className="text-sm">
            {tag}
          </div>
        ))}
      </div>
    </ScrollArea>
  )
}
directions.tsx
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { FileTextIcon } from "lucide-react";
import { Fragment } from "react";

const tags = Array.from({ length: 40 }, (_, index) => `v1.2.0-beta.${40 - index}`);

const files = [
  { name: "Q3 report.pdf", size: "2.4 MB" },
  { name: "Roadmap.fig", size: "18 MB" },
  { name: "Invoices.csv", size: "84 KB" },
  { name: "Brand guide.pdf", size: "9.1 MB" },
  { name: "Logo.svg", size: "6 KB" },
  { name: "Launch notes.md", size: "3 KB" },
  { name: "Pitch.key", size: "42 MB" },
];

/*
 * Both directions side by side, named underneath. Scroll either one and a soft fade appears at the edge you
 * left; point at it and the scrollbar shows, thickening under the pointer.
 */
export default function ScrollAreaDirections() {
  return (
    <ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
      <li className="flex flex-col items-center gap-2">
        <ScrollArea aria-label="Tags" className="h-64 w-48 rounded-lg">
          <div className="p-4">
            <p className="mb-3 text-sm font-medium">Tags</p>
            {tags.map((tag, index) => (
              <Fragment key={tag}>
                {index > 0 ? <div aria-hidden className="my-2 h-px bg-border" /> : null}
                <p className="text-sm">{tag}</p>
              </Fragment>
            ))}
          </div>
        </ScrollArea>
        <span className="text-xs text-muted-foreground">Vertical</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <ScrollArea aria-label="Recent files" className="w-72 rounded-lg">
          <ul className="flex w-max gap-3 p-4">
            {files.map((file) => (
              <li key={file.name} className="flex w-32 flex-col gap-3 rounded-md bg-muted p-3">
                <FileTextIcon aria-hidden className="size-(--size-icon-lg) text-muted-foreground" />
                <span className="flex flex-col gap-0.5">
                  <span className="truncate text-sm font-medium">{file.name}</span>
                  <span className="text-xs text-muted-foreground">{file.size}</span>
                </span>
              </li>
            ))}
          </ul>
          <ScrollBar orientation="horizontal" />
        </ScrollArea>
        <span className="text-xs text-muted-foreground">Horizontal</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Copy the source into your project.

    components/ui/scroll-area.tsx
    "use client";
    
    import { cn } from "@/lib/utils";
    import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
    import { type ComponentProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
    
    /*
     * Scroll Area (owner's roadmap, 2026-10-16: "Scrolling with soft fades at the edges"), on Radix Scroll Area, with
     * shadcn's parts: ScrollArea and ScrollBar.
     * - Native scrolling (wheel, touch, keyboard), with a slim scrollbar laid over the content in place of the
     *   browser's. It fades in while the pointer is over the area and out after (Radix's "hover" type).
     * - The thumb is a thin line that thickens when you point at the bar or drag it, so it's easy to grab. It grows
     *   from the area's edge with a scale (a transform), never a width change.
     * - Soft fades at the edges that can scroll, as in Select's list: one per side, shown only while there's more
     *   that way. They're overlays outside the scroller (inside, they'd scroll with the content and add to its size)
     *   and take the area's own background, so on a card or a popover give it that colour (`bg-card`,
     *   `bg-popover`). Scroll-edge fades are a plain fade (ADR 0011), and the first paint shows them without fading.
     * - Named with aria-label or aria-labelledby, the scroller becomes a focusable region, so keyboard users can reach
     *   it and scroll even when nothing inside can take focus.
     */
    
    type Edges = { top: boolean; bottom: boolean; left: boolean; right: boolean };
    
    /** Which sides have more to scroll to. In right-to-left, scrollLeft runs from 0 (the right end) to negative. */
    function edgesOf(node: HTMLElement): Edges {
      const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = node;
      const wide = scrollWidth - clientWidth;
      const fromLeft = node.closest("[dir]")?.getAttribute("dir") === "rtl" ? wide + scrollLeft : scrollLeft;
      return {
        top: scrollTop > 1,
        bottom: scrollTop < scrollHeight - clientHeight - 1,
        left: fromLeft > 1,
        right: fromLeft < wide - 1,
      };
    }
    
    const sameEdges = (a: Edges, b: Edges) =>
      a.top === b.top && a.bottom === b.bottom && a.left === b.left && a.right === b.right;
    
    /** Each fade: where it lies, and which way it thins out (a static mask over the area's own colour) */
    const fades = {
      top: "inset-x-0 top-0 h-8 mask-b-from-0%",
      bottom: "inset-x-0 bottom-0 h-8 mask-t-from-0%",
      left: "inset-y-0 left-0 w-8 mask-r-from-0%",
      right: "inset-y-0 right-0 w-8 mask-l-from-0%",
    } as const;
    
    export type ScrollAreaProps = ComponentProps<typeof ScrollAreaPrimitive.Root>;
    
    /**
     * A scrolling box with a slim scrollbar and soft fades at the edges that can scroll. Give it a height (or a width
     * for sideways scrolling, with a horizontal ScrollBar inside) and a name with aria-label.
     */
    function ScrollArea({
      className,
      children,
      "aria-label": label,
      "aria-labelledby": labelledBy,
      ...props
    }: ScrollAreaProps) {
      const viewport = useRef<HTMLDivElement>(null);
      const [edges, setEdges] = useState<Edges>({ top: false, bottom: false, left: false, right: false });
      // The fades fade only once the first measure has shown them, so nothing animates on first paint
      const [settled, setSettled] = useState(false);
      const named = label !== undefined || labelledBy !== undefined;
    
      const update = useCallback(() => {
        const node = viewport.current;
        if (!node) return;
        const next = edgesOf(node);
        setEdges((current) => (sameEdges(current, next) ? current : next));
      }, []);
    
      // Measure before the first paint, and again whenever the area or its content changes size
      useLayoutEffect(() => {
        const node = viewport.current;
        if (!node) return;
        update();
        const observer = new ResizeObserver(update);
        observer.observe(node);
        if (node.firstElementChild) observer.observe(node.firstElementChild);
        return () => observer.disconnect();
      }, [update]);
    
      useEffect(() => {
        const frame = requestAnimationFrame(() => setSettled(true));
        return () => cancelAnimationFrame(frame);
      }, []);
    
      return (
        <ScrollAreaPrimitive.Root
          data-slot="scroll-area"
          className={cn("relative isolate overflow-hidden bg-background", className)}
          {...props}
        >
          <ScrollAreaPrimitive.Viewport
            ref={viewport}
            data-slot="scroll-area-viewport"
            onScroll={update}
            {...(named
              ? { role: "region", "aria-label": label, "aria-labelledby": labelledBy, tabIndex: 0 }
              : {})}
            // The focus ring sits inside the edge, where the area's rounded corners don't clip it
            className="size-full outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:-outline-offset-(--focus-ring-width)"
            style={{ borderRadius: "inherit" }}
          >
            {children}
          </ScrollAreaPrimitive.Viewport>
          {(Object.keys(fades) as (keyof Edges)[]).map((side) => (
            <div
              key={side}
              aria-hidden
              data-slot="scroll-area-fade"
              data-side={side}
              data-state={edges[side] ? "visible" : "hidden"}
              className={cn(
                "pointer-events-none absolute bg-inherit data-[state=hidden]:opacity-0",
                settled && "transition-opacity duration-(--duration-fast) ease-out",
                fades[side],
              )}
            />
          ))}
          <ScrollBar />
          <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" />
        </ScrollAreaPrimitive.Root>
      );
    }
    
    /**
     * The scrollbar. ScrollArea has a vertical one; add `<ScrollBar orientation="horizontal" />` inside it to scroll
     * sideways. It stays mounted, so it can fade in and out.
     */
    function ScrollBar({
      className,
      orientation = "vertical",
      ...props
    }: ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
      const vertical = orientation === "vertical";
      return (
        <ScrollAreaPrimitive.ScrollAreaScrollbar
          data-slot="scroll-area-scrollbar"
          orientation={orientation}
          forceMount
          className={cn(
            // Above the fades, so its ends never fade with the content
            "group/scrollbar z-10 flex touch-none p-px select-none",
            "transition-opacity duration-(--duration-base) ease-out data-[state=hidden]:pointer-events-none data-[state=hidden]:opacity-0",
            vertical ? "h-full w-2.5" : "h-2.5 flex-col",
            className,
          )}
          {...props}
        >
          {/* Radix moves the thumb with a transform, so the line we see is its ::before, which scales on its own:
              half its width at rest, at the area's edge, and the full 8px while you point at the bar or drag it */}
          <ScrollAreaPrimitive.ScrollAreaThumb
            data-slot="scroll-area-thumb"
            className={cn(
              "relative flex-1 rounded-full",
              "before:absolute before:inset-0 before:rounded-full before:bg-border before:transition before:duration-(--duration-fast) before:ease-out",
              "group-hover/scrollbar:before:bg-muted-foreground/50 group-active/scrollbar:before:bg-muted-foreground/50",
              vertical
                ? "before:origin-right before:scale-x-50 group-hover/scrollbar:before:scale-x-100 group-active/scrollbar:before:scale-x-100 rtl:before:origin-left"
                : "before:origin-bottom before:scale-y-50 group-hover/scrollbar:before:scale-y-100 group-active/scrollbar:before:scale-y-100",
            )}
          />
        </ScrollAreaPrimitive.ScrollAreaScrollbar>
      );
    }
    
    export { ScrollArea, ScrollBar };
  4. 4

    Update the import paths to match your project.

API reference

ScrollArea

The scrolling box, with a vertical scrollbar. Give it a height (or a width to scroll sideways). Also accepts every Radix Scroll Area Root prop.

PropTypeDefaultDescription
aria-label?string–Names the area. A named area is a focusable region, so keyboard users can reach it and scroll with the arrow keys.
aria-labelledby?string–Names the area after a visible heading's id, with the same effect as aria-label.
type?"hover" | "scroll" | "auto" | "always""hover"When the scrollbar shows: while the pointer is over the area, while scrolling, whenever there's more to scroll, or always.
scrollHideDelay?number600Milliseconds before the scrollbar fades out, for the hover and scroll types.
className?string–The fades take the area's background, bg-background by default. On a card or a popover, add bg-card or bg-popover so they match.

ScrollBar

A scrollbar. ScrollArea already has the vertical one; put a horizontal one inside it to scroll sideways. Also accepts every Radix Scroll Area Scrollbar prop.

PropTypeDefaultDescription
orientation?"vertical" | "horizontal""vertical"Which way it scrolls.

Related components

  • InvoiceStatusAmount
    INV-1048Paid$1,250.00
    INV-1047Pending$480.00
    INV-1046Overdue$2,900.00
    Table

    An accessible React table for Next.js and Tailwind CSS: rows and columns with sortable headers, selectable rows and rows that glide when sorted.

  • Select

    An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.

  • OMJLIN+3
    Avatar

    An accessible React avatar for Next.js and Tailwind CSS: a picture or initials in three sizes, alone or in an overlapping group, built on Radix Avatar.