ComponentProduct
Scroll area
Scrollable content with edge fades and a button to scroll down.
$ npx context-ds add scroll-areaUsage
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.