Scroll Area
Una región con scroll y una barra propia, acorde al tema, que aparece al pasar el cursor o al desplazarse.
NuevoReleases
- v2.24.0Security fixes
- v2.23.0Improvements
- v2.22.0Improvements
- v2.21.0Security fixes
- v2.20.0Improvements
- v2.19.0Improvements
- v2.18.0Security fixes
- v2.17.0Improvements
- v2.16.0Improvements
- v2.15.0Security fixes
- v2.14.0Improvements
- v2.13.0Improvements
- v2.12.0Security fixes
- v2.11.0Improvements
- v2.10.0Improvements
- v2.9.0Security fixes
- v2.8.0Improvements
- v2.7.0Improvements
- v2.6.0Security fixes
- v2.5.0Improvements
- v2.4.0Improvements
- v2.3.0Security fixes
- v2.2.0Improvements
- v2.1.0Improvements
1import { ScrollArea } from '@/components/ui/scroll-area';23const versions = Array.from({ length: 24 }, (_, index) => {4 const minor = 24 - index;5 return { tag: `v2.${minor}.0`, note: minor % 3 === 0 ? 'Security fixes' : 'Improvements' };6});78export function Default() {9 return (10 <ScrollArea className="border-border bg-card h-72 w-56 rounded-lg border">11 <div className="p-3">12 <p className="text-muted-foreground mb-3 px-1 text-xs font-medium tracking-wide uppercase">13 Releases14 </p>15 <ul className="flex flex-col">16 {versions.map((version) => (17 <li18 key={version.tag}19 className="border-border flex items-center justify-between border-b px-1 py-2 text-sm last:border-0"20 >21 <span className="font-medium">{version.tag}</span>22 <span className="text-muted-foreground text-xs">{version.note}</span>23 </li>24 ))}25 </ul>26 </div>27 </ScrollArea>28 );29}
Instalación
Copia y pega el siguiente código en tu proyecto.
1'use client';23import * as React from 'react';4import { cn } from '../lib/cn';56type ScrollAreaType = 'auto' | 'always' | 'hover' | 'scroll';7type ScrollAreaOrientation = 'vertical' | 'horizontal' | 'both';8type ScrollBarOrientation = 'vertical' | 'horizontal';910interface ScrollAreaContextValue {11 viewportRef: React.RefObject<HTMLDivElement | null>;12 type: ScrollAreaType;13 scrollHideDelay: number;14 hovering: boolean;15 scrolling: boolean;16}1718const ScrollAreaContext = React.createContext<ScrollAreaContextValue | null>(null);1920function useScrollAreaContext(): ScrollAreaContextValue {21 const context = React.use(ScrollAreaContext);22 if (!context) throw new Error('ScrollArea parts must be used within ScrollArea');23 return context;24}2526interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {27 type?: ScrollAreaType;28 orientation?: ScrollAreaOrientation;29 scrollHideDelay?: number;30 viewportRef?: React.Ref<HTMLDivElement>;31 viewportClassName?: string;32 label?: string;33}3435const MIN_THUMB_SIZE = 18;3637function assignRef<T>(ref: React.Ref<T> | undefined, node: T) {38 if (typeof ref === 'function') ref(node);39 else if (ref) ref.current = node;40}4142const ScrollAreaRoot = ({43 type = 'hover',44 orientation = 'vertical',45 scrollHideDelay = 600,46 viewportRef: externalViewportRef,47 viewportClassName,48 label,49 className,50 children,51 onPointerEnter,52 onPointerLeave,53 ref,54 ...props55}: ScrollAreaProps & { ref?: React.Ref<HTMLDivElement> }) => {56 const viewportRef = React.useRef<HTMLDivElement>(null);57 const [hovering, setHovering] = React.useState(false);58 const [scrolling, setScrolling] = React.useState(false);59 const hideTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);6061 const mergedViewportRef = React.useCallback(62 (node: HTMLDivElement | null) => {63 viewportRef.current = node;64 assignRef(externalViewportRef, node);65 },66 [externalViewportRef],67 );6869 const handleScroll = React.useCallback(() => {70 setScrolling(true);71 if (hideTimer.current) clearTimeout(hideTimer.current);72 hideTimer.current = setTimeout(() => setScrolling(false), scrollHideDelay);73 }, [scrollHideDelay]);7475 React.useEffect(() => {76 return () => {77 if (hideTimer.current) clearTimeout(hideTimer.current);78 };79 }, []);8081 const context = React.useMemo(82 () => ({ viewportRef, type, scrollHideDelay, hovering, scrolling }),83 [type, scrollHideDelay, hovering, scrolling],84 );8586 const showVertical = orientation === 'vertical' || orientation === 'both';87 const showHorizontal = orientation === 'horizontal' || orientation === 'both';8889 return (90 <ScrollAreaContext value={context}>91 <div92 ref={ref}93 data-slot="scroll-area"94 data-orientation={orientation}95 className={cn('relative overflow-hidden', className)}96 onPointerEnter={(event) => {97 onPointerEnter?.(event);98 setHovering(true);99 }}100 onPointerLeave={(event) => {101 onPointerLeave?.(event);102 setHovering(false);103 }}104 {...props}105 >106 <div107 ref={mergedViewportRef}108 data-slot="scroll-area-viewport"109 tabIndex={0}110 role={label ? 'region' : undefined}111 aria-label={label}112 onScroll={handleScroll}113 className={cn(114 'focus-visible:ring-ring size-full [scrollbar-width:none] rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-inset [&::-webkit-scrollbar]:hidden',115 showVertical ? 'overflow-y-auto' : 'overflow-y-hidden',116 showHorizontal ? 'overflow-x-auto' : 'overflow-x-hidden',117 viewportClassName,118 )}119 >120 {children}121 </div>122 {showVertical && <ScrollAreaBar orientation="vertical" />}123 {showHorizontal && <ScrollAreaBar orientation="horizontal" />}124 </div>125 </ScrollAreaContext>126 );127};128ScrollAreaRoot.displayName = 'ScrollArea';129130interface ScrollAreaBarProps extends React.HTMLAttributes<HTMLDivElement> {131 orientation?: ScrollBarOrientation;132 forceMount?: boolean;133}134135interface ThumbMetrics {136 overflow: boolean;137 size: number;138 offset: number;139}140141const ScrollAreaBar = ({142 orientation = 'vertical',143 forceMount = false,144 className,145 ref,146 ...props147}: ScrollAreaBarProps & { ref?: React.Ref<HTMLDivElement> }) => {148 const { viewportRef, type, hovering, scrolling } = useScrollAreaContext();149 const trackRef = React.useRef<HTMLDivElement>(null);150 const dragRef = React.useRef<{ start: number; scroll: number } | null>(null);151 const [metrics, setMetrics] = React.useState<ThumbMetrics>({152 overflow: false,153 size: 0,154 offset: 0,155 });156 const [dragging, setDragging] = React.useState(false);157 const isVertical = orientation === 'vertical';158159 const measure = React.useCallback(() => {160 const viewport = viewportRef.current;161 const track = trackRef.current;162 if (!viewport || !track) return;163164 const scrollSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth;165 const clientSize = isVertical ? viewport.clientHeight : viewport.clientWidth;166 const scrollPosition = isVertical ? viewport.scrollTop : viewport.scrollLeft;167 const trackSize = isVertical ? track.clientHeight : track.clientWidth;168 const overflow = scrollSize > clientSize + 1;169170 if (!overflow || trackSize === 0) {171 setMetrics((previous) =>172 previous.overflow ? { overflow: false, size: 0, offset: 0 } : previous,173 );174 return;175 }176177 const size = Math.max(Math.round((clientSize / scrollSize) * trackSize), MIN_THUMB_SIZE);178 const maxScroll = scrollSize - clientSize;179 const offset = Math.round((scrollPosition / maxScroll) * (trackSize - size));180181 setMetrics((previous) =>182 previous.overflow && previous.size === size && previous.offset === offset183 ? previous184 : { overflow: true, size, offset },185 );186 }, [isVertical, viewportRef]);187188 React.useEffect(() => {189 const viewport = viewportRef.current;190 if (!viewport) return;191192 measure();193194 let frame = 0;195 const schedule = () => {196 if (frame) return;197 frame = requestAnimationFrame(() => {198 frame = 0;199 measure();200 });201 };202 viewport.addEventListener('scroll', schedule, { passive: true });203204 let observer: ResizeObserver | undefined;205 if (typeof ResizeObserver !== 'undefined') {206 observer = new ResizeObserver(schedule);207 observer.observe(viewport);208 for (const child of Array.from(viewport.children)) observer.observe(child);209 }210211 let mutations: MutationObserver | undefined;212 if (typeof MutationObserver !== 'undefined') {213 mutations = new MutationObserver((records) => {214 for (const record of records) {215 for (const node of Array.from(record.addedNodes)) {216 if (node instanceof Element) observer?.observe(node);217 }218 for (const node of Array.from(record.removedNodes)) {219 if (node instanceof Element) observer?.unobserve(node);220 }221 }222 schedule();223 });224 mutations.observe(viewport, { childList: true });225 }226227 return () => {228 viewport.removeEventListener('scroll', schedule);229 if (frame) cancelAnimationFrame(frame);230 observer?.disconnect();231 mutations?.disconnect();232 };233 }, [measure, viewportRef]);234235 const scrollTo = (position: number) => {236 const viewport = viewportRef.current;237 if (!viewport) return;238 if (isVertical) viewport.scrollTop = position;239 else viewport.scrollLeft = position;240 };241242 const scrollRatio = () => {243 const viewport = viewportRef.current;244 const track = trackRef.current;245 if (!viewport || !track) return 0;246 const scrollSize = isVertical ? viewport.scrollHeight : viewport.scrollWidth;247 const clientSize = isVertical ? viewport.clientHeight : viewport.clientWidth;248 const trackSize = isVertical ? track.clientHeight : track.clientWidth;249 const travel = trackSize - metrics.size;250 return travel > 0 ? (scrollSize - clientSize) / travel : 0;251 };252253 const handleThumbPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {254 event.preventDefault();255 event.stopPropagation();256 const viewport = viewportRef.current;257 if (!viewport) return;258 event.currentTarget.setPointerCapture(event.pointerId);259 dragRef.current = {260 start: isVertical ? event.clientY : event.clientX,261 scroll: isVertical ? viewport.scrollTop : viewport.scrollLeft,262 };263 setDragging(true);264 };265266 const handleThumbPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {267 const drag = dragRef.current;268 if (!drag) return;269 const current = isVertical ? event.clientY : event.clientX;270 scrollTo(drag.scroll + (current - drag.start) * scrollRatio());271 };272273 const handleThumbPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {274 dragRef.current = null;275 setDragging(false);276 if (event.currentTarget.hasPointerCapture(event.pointerId)) {277 event.currentTarget.releasePointerCapture(event.pointerId);278 }279 };280281 const handleTrackPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {282 const track = trackRef.current;283 if (!track) return;284 const rect = track.getBoundingClientRect();285 const position = isVertical ? event.clientY - rect.top : event.clientX - rect.left;286 scrollTo((position - metrics.size / 2) * scrollRatio());287 };288289 const visible =290 forceMount ||291 (metrics.overflow &&292 (type === 'always' ||293 type === 'auto' ||294 dragging ||295 (type === 'hover' && (hovering || scrolling)) ||296 (type === 'scroll' && scrolling)));297298 const thumbStyle = isVertical299 ? { height: metrics.size, transform: `translateY(${metrics.offset}px)` }300 : { width: metrics.size, transform: `translateX(${metrics.offset}px)` };301302 return (303 <div304 ref={(node) => {305 trackRef.current = node;306 if (typeof ref === 'function') ref(node);307 else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;308 }}309 data-slot="scroll-area-scrollbar"310 data-orientation={orientation}311 data-state={visible ? 'visible' : 'hidden'}312 aria-hidden="true"313 onPointerDown={handleTrackPointerDown}314 className={cn(315 'absolute flex touch-none transition-opacity duration-150 select-none data-[state=hidden]:pointer-events-none data-[state=hidden]:opacity-0',316 isVertical317 ? 'top-0 right-0 h-full w-2.5 border-l border-l-transparent p-px'318 : 'bottom-0 left-0 h-2.5 w-full flex-col border-t border-t-transparent p-px',319 className,320 )}321 {...props}322 >323 <div324 data-slot="scroll-area-thumb"325 data-dragging={dragging ? '' : undefined}326 onPointerDown={handleThumbPointerDown}327 onPointerMove={handleThumbPointerMove}328 onPointerUp={handleThumbPointerUp}329 onPointerCancel={handleThumbPointerUp}330 style={thumbStyle}331 className={cn(332 'bg-border hover:bg-muted-foreground/50 data-[dragging]:bg-muted-foreground/60 relative rounded-full transition-colors',333 isVertical ? 'w-full' : 'h-full',334 )}335 />336 </div>337 );338};339ScrollAreaBar.displayName = 'ScrollAreaBar';340341const ScrollArea = Object.assign(ScrollAreaRoot, {342 Bar: ScrollAreaBar,343});344345export { ScrollArea, useScrollAreaContext };346export type { ScrollAreaProps, ScrollAreaBarProps, ScrollAreaType, ScrollAreaOrientation };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { ScrollArea } from '@/components/ui/scroll-area';
1<ScrollArea className="h-72 w-56 rounded-md border">2 <div className="p-4">Contenido largo</div>3</ScrollArea>
La raíz envuelve tu contenido en un viewport que hace scroll nativo con la barra del navegador oculta, y dibuja su propio pulgar encima. Rueda, touch, teclado y arrastre siguen funcionando porque el viewport es un elemento normal con overflow. El pulgar se arrastra con el puntero y la pista salta al hacer click. El viewport recibe foco con
Tab, así que las flechas lo desplazan aunque adentro no haya nada enfocable. Si le pasás label se vuelve una region con nombre.Composición
ScrollArea└── ScrollArea.Bar
La raíz renderiza una
Bar por cada dirección de orientation. Agrega una Bar propia con forceMount cuando necesites una que nunca se oculte.Variantes
Predeterminado (Default)
Scroll vertical. Con
type="hover" (el valor por defecto) la barra aparece mientras el puntero está sobre el área o el contenido se desplaza.Releases
- v2.24.0Security fixes
- v2.23.0Improvements
- v2.22.0Improvements
- v2.21.0Security fixes
- v2.20.0Improvements
- v2.19.0Improvements
- v2.18.0Security fixes
- v2.17.0Improvements
- v2.16.0Improvements
- v2.15.0Security fixes
- v2.14.0Improvements
- v2.13.0Improvements
- v2.12.0Security fixes
- v2.11.0Improvements
- v2.10.0Improvements
- v2.9.0Security fixes
- v2.8.0Improvements
- v2.7.0Improvements
- v2.6.0Security fixes
- v2.5.0Improvements
- v2.4.0Improvements
- v2.3.0Security fixes
- v2.2.0Improvements
- v2.1.0Improvements
1import { ScrollArea } from '@/components/ui/scroll-area';23const versions = Array.from({ length: 24 }, (_, index) => {4 const minor = 24 - index;5 return { tag: `v2.${minor}.0`, note: minor % 3 === 0 ? 'Security fixes' : 'Improvements' };6});78export function Default() {9 return (10 <ScrollArea className="border-border bg-card h-72 w-56 rounded-lg border">11 <div className="p-3">12 <p className="text-muted-foreground mb-3 px-1 text-xs font-medium tracking-wide uppercase">13 Releases14 </p>15 <ul className="flex flex-col">16 {versions.map((version) => (17 <li18 key={version.tag}19 className="border-border flex items-center justify-between border-b px-1 py-2 text-sm last:border-0"20 >21 <span className="font-medium">{version.tag}</span>22 <span className="text-muted-foreground text-xs">{version.note}</span>23 </li>24 ))}25 </ul>26 </div>27 </ScrollArea>28 );29}
Horizontal
orientation="horizontal" oculta el overflow vertical y dibuja la barra en el borde inferior.Virginiaus-east-112 ms
Oregonus-west-248 ms
Frankfurteu-central-196 ms
Sao Paulosa-east-1140 ms
Singaporeap-southeast-1210 ms
Sydneyap-southeast-2245 ms
Tokyoap-northeast-1180 ms
1import { ScrollArea } from '@/components/ui/scroll-area';23const regions = [4 { city: 'Virginia', code: 'us-east-1', latency: '12 ms' },5 { city: 'Oregon', code: 'us-west-2', latency: '48 ms' },6 { city: 'Frankfurt', code: 'eu-central-1', latency: '96 ms' },7 { city: 'Sao Paulo', code: 'sa-east-1', latency: '140 ms' },8 { city: 'Singapore', code: 'ap-southeast-1', latency: '210 ms' },9 { city: 'Sydney', code: 'ap-southeast-2', latency: '245 ms' },10 { city: 'Tokyo', code: 'ap-northeast-1', latency: '180 ms' },11];1213export function Horizontal() {14 return (15 <ScrollArea orientation="horizontal" className="w-full max-w-md">16 <div className="flex gap-3 p-1 pb-3">17 {regions.map((region) => (18 <div19 key={region.code}20 className="border-border bg-card flex w-36 shrink-0 flex-col gap-1 rounded-lg border p-3"21 >22 <span className="text-sm font-medium">{region.city}</span>23 <span className="text-muted-foreground font-mono text-xs">{region.code}</span>24 <span className="text-muted-foreground text-xs">{region.latency}</span>25 </div>26 ))}27 </div>28 </ScrollArea>29 );30}
Ambas direcciones
orientation="both" para tablas anchas. type="always" mantiene las barras visibles siempre que el contenido desborde.| Route | Method | p50 | p95 | p99 | Errors | Requests | Region |
|---|---|---|---|---|---|---|---|
| /api/orders | GET | 32 ms | 120 ms | 340 ms | 0.1% | 1.2M | us-east-1 |
| /api/orders | POST | 58 ms | 210 ms | 610 ms | 0.4% | 240K | us-east-1 |
| /api/customers | GET | 21 ms | 88 ms | 190 ms | 0.0% | 980K | eu-central-1 |
| /api/invoices | GET | 44 ms | 160 ms | 420 ms | 0.2% | 410K | eu-central-1 |
| /api/invoices/:id/pdf | GET | 310 ms | 900 ms | 1.8 s | 1.1% | 32K | us-west-2 |
| /api/webhooks | POST | 12 ms | 40 ms | 95 ms | 0.0% | 3.4M | ap-southeast-1 |
| /api/search | GET | 80 ms | 260 ms | 700 ms | 0.3% | 620K | us-east-1 |
| /api/exports | POST | 1.2 s | 3.4 s | 6.1 s | 2.0% | 8K | sa-east-1 |
| /api/auth/session | GET | 9 ms | 30 ms | 70 ms | 0.0% | 5.1M | us-east-1 |
| /api/auth/login | POST | 140 ms | 400 ms | 900 ms | 0.8% | 150K | us-east-1 |
1import { ScrollArea } from '@/components/ui/scroll-area';23const columns = ['Route', 'Method', 'p50', 'p95', 'p99', 'Errors', 'Requests', 'Region'];45const rows = [6 ['/api/orders', 'GET', '32 ms', '120 ms', '340 ms', '0.1%', '1.2M', 'us-east-1'],7 ['/api/orders', 'POST', '58 ms', '210 ms', '610 ms', '0.4%', '240K', 'us-east-1'],8 ['/api/customers', 'GET', '21 ms', '88 ms', '190 ms', '0.0%', '980K', 'eu-central-1'],9 ['/api/invoices', 'GET', '44 ms', '160 ms', '420 ms', '0.2%', '410K', 'eu-central-1'],10 ['/api/invoices/:id/pdf', 'GET', '310 ms', '900 ms', '1.8 s', '1.1%', '32K', 'us-west-2'],11 ['/api/webhooks', 'POST', '12 ms', '40 ms', '95 ms', '0.0%', '3.4M', 'ap-southeast-1'],12 ['/api/search', 'GET', '80 ms', '260 ms', '700 ms', '0.3%', '620K', 'us-east-1'],13 ['/api/exports', 'POST', '1.2 s', '3.4 s', '6.1 s', '2.0%', '8K', 'sa-east-1'],14 ['/api/auth/session', 'GET', '9 ms', '30 ms', '70 ms', '0.0%', '5.1M', 'us-east-1'],15 ['/api/auth/login', 'POST', '140 ms', '400 ms', '900 ms', '0.8%', '150K', 'us-east-1'],16];1718export function Both() {19 return (20 <ScrollArea21 orientation="both"22 type="always"23 className="border-border bg-card h-56 w-full max-w-md rounded-lg border"24 >25 <table className="w-max min-w-full text-left text-sm">26 <thead className="bg-muted/60 text-muted-foreground sticky top-0 text-xs">27 <tr>28 {columns.map((column) => (29 <th key={column} className="px-3 py-2 font-medium whitespace-nowrap">30 {column}31 </th>32 ))}33 </tr>34 </thead>35 <tbody>36 {rows.map((row) => (37 <tr key={`${row[0]}-${row[1]}`} className="border-border border-t">38 {row.map((cell, index) => (39 <td40 key={columns[index]}41 className={42 index === 043 ? 'px-3 py-2 font-mono whitespace-nowrap'44 : 'px-3 py-2 whitespace-nowrap'45 }46 >47 {cell}48 </td>49 ))}50 </tr>51 ))}52 </tbody>53 </table>54 </ScrollArea>55 );56}
Referencia de API
ScrollArea
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "both" | "vertical" | Qué direcciones hacen scroll |
type | "auto" | "always" | "hover" | "scroll" | "hover" | Cuándo se ve la barra. Todas requieren overflow |
scrollHideDelay | number | 600 | Milisegundos antes de ocultar la barra tras el scroll |
viewportRef | React.Ref<HTMLDivElement> | - | Ref al elemento que hace scroll, para scrollTo y similares |
viewportClassName | string | - | Clases para el viewport |
label | string | - | Le da nombre al viewport y role="region" |
className | string | - | Clases para la raíz. Dale una altura o un ancho |
ScrollArea.Bar
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | Qué eje controla la barra |
forceMount | boolean | false | Mantenerla visible sin importar type |
className | string | - | Clases CSS adicionales |
¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.