Scroll areaDesign system
ComponentProduct

Scroll area

Scrollable content with edge fades and a button to scroll down.

npx context-ds add scroll-area
In the product

Usage

Use it for

Long lists inside panels.

Dependencies: lucide-react

Example

examples/scroll-area.tsx
import { ScrollArea } from "@/components/ui/scroll-area";export function Product() {  return (    <div className="flex h-48 w-64 flex-col rounded-card bg-surface shadow-card">      <ScrollArea className="px-3 py-2 text-body-sm">        {Array.from({ length: 20 }, (_, i) => (          <p key={i} className="py-1 text-fg-muted">            Request {i + 1}          </p>        ))}      </ScrollArea>    </div>  );}

Source

components/ui/scroll-area.tsx
"use client";import * as React from "react";import { ChevronDown } from "lucide-react";import { cn } from "@/lib/utils";const EDGE_FADE_PX = 28;/** * The dashboard's scroll area, which says when there's more: no scrollbar taking room on one side (so the padding stays * even), the content fading out at whichever edge has more past it, and while there's more below, a small chevron at * the bottom that scrolls the rest into view. The chevron fades and rises in 200ms ease-out. */function ScrollArea({ className, children, ...props }: React.ComponentProps<"div">) {  const ref = React.useRef<HTMLDivElement>(null);  const [edges, setEdges] = React.useState({ more: false, above: false, below: false });  React.useLayoutEffect(() => {    const area = ref.current;    if (!area) return;    const measure = () => {      const more = area.scrollHeight > area.clientHeight + 1;      const above = more && area.scrollTop > 1;      const below = more && area.scrollTop + area.clientHeight < area.scrollHeight - 1;      setEdges((previous) => (previous.more === more && previous.above === above && previous.below === below ? previous : { more, above, below }));    };    measure();    const observer = new ResizeObserver(measure);    observer.observe(area);    for (const child of Array.from(area.children)) observer.observe(child);    area.addEventListener("scroll", measure, { passive: true });    return () => {      observer.disconnect();      area.removeEventListener("scroll", measure);    };  }, []);  const mask = `linear-gradient(to bottom, ${edges.above ? "transparent" : "black"}, black ${EDGE_FADE_PX}px, black calc(100% - ${EDGE_FADE_PX}px), ${edges.below ? "transparent" : "black"})`;  return (    <div data-slot="scroll-area" className="relative flex min-h-0 flex-1 flex-col">      <div        ref={ref}        data-slot="scroll-area-viewport"        className={cn("min-h-0 flex-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", className)}        style={edges.more ? { maskImage: mask, WebkitMaskImage: mask } : undefined}        {...props}      >        {children}      </div>      <button        type="button"        tabIndex={-1}        aria-hidden        onClick={() => ref.current?.scrollBy({ top: ref.current.clientHeight * 0.8, behavior: "smooth" })}        className={cn(          "absolute bottom-1 left-1/2 grid size-6 -translate-x-1/2 place-items-center rounded-pill bg-surface text-fg-muted shadow-card transition-[opacity,translate] duration-200 ease-out hover:text-fg motion-reduce:transition-[opacity]",          edges.below ? "opacity-100" : "pointer-events-none translate-y-1 opacity-0",        )}      >        <ChevronDown className="size-3.5" strokeWidth={1.75} />      </button>    </div>  );}export { ScrollArea };

Reference: context-dashboard c0ddf7a src/components/ui/scroll-area.tsx.