Scroll Area
Constrain a real native scroll viewport while preserving named keyboard access, content identity and alternatives to thumb dragging.
Constrain a task intentionally
Use ScrollArea when a bounded region helps a continuing task, such as browsing a substantial record list beside an applied result. Ordinary page flow is often clearer for reading; avoid nested scrolling where content could simply reflow.
A scrollbar does not define a list, table, selection or permission model. Put meaningful structures and real controls inside it. The example uses a native list and pressed buttons, with a separate actual loaded-record result.
Anatomy and actual geometry
| Part | Contract |
|---|---|
| Root | Styled installed div/ref with relative positioning, clipped outer overflow, shrinkable bounded width and caller-defined height/width. Root is not the scrolling element. |
| Viewport | Actual native scrolling div. New viewportProps/viewportRef reach its name, focus, handlers and scroll methods. Installed overflow logic makes the viewport focusable when needed. |
| Content | Automatic installed Content observes size changes; caller supplies meaningful list/table structure within it. No data virtualization or item-count reduction is supplied. |
| Tracks / thumbs | Automatic vertical/horizontal installed parts. Authored tracks are 24px across, without opacity transitions; thumbs have minimum 24px dimensions and readable fill. Drag/wheel interaction is real, not purely decorative. |
| Direction / corner | Explicit ltr/rtl configures both DOM and installed direction context. Vertical track uses logical inline-end. Automatic Corner handles both-axis intersection. |
| Focus / content padding | Viewport has an independent inset 2px focus outline. Reserve space for overlaid tracks when essential content/actions could otherwise be obscured; example content has 28px padding. |
Actual records, overflow and retained result
Browse 24 English-only records, focus the named viewport and use native scroll keys, wheel or the authored thumb. First/last actions use the actual viewport ref. Selecting a real record changes the loaded content outside the scrolling region.
Short-list, wide-content, height and RTL controls change actual overflow. A shorter list can exclude a selected record while the loaded result remains retained; this is the explicit local policy, not automatic stale-selection reconciliation. The fixture has no remote paging, persistence, authorization or source-authority result.
Actual vertical scroll position: 0px.
Actual retained content for local record 1.
List-size, width and height controls change actual overflow. A selected record remains loaded when the shorter list excludes it; clearing/filter reconciliation is a separate task policy. The first/last actions provide alternatives to dragging the custom thumb.
'use client';
import {useRef,useState,type CSSProperties} from 'react';
import {ScrollArea,Button} from '@mobtranslate/ui';
const action:CSSProperties={minWidth:0,maxWidth:'100%',minHeight:44,height:'auto',whiteSpace:'normal',overflowWrap:'anywhere'};
const records=Array.from({length:24},(_,index)=>({id:index+1,label:`Local English-only record ${index+1}`,text:`Actual retained content for local record ${index+1}.`}));
export function ScrollRecordsExample(){
const [short,setShort]=useState(false);
const [wide,setWide]=useState(false);
const [rtl,setRtl]=useState(false);
const [compact,setCompact]=useState(false);
const [selected,setSelected]=useState(1);
const [scroll,setScroll]=useState(0);
const viewport=useRef<HTMLDivElement>(null);
const visible=short?records.slice(0,2):records;
return <div data-scroll-lab className="space-y-4"><div className="flex flex-wrap gap-3"><Button style={action} type="button" variant="outline" aria-pressed={short} onClick={()=>setShort(v=>!v)}>Use short local record list</Button><Button style={action} type="button" variant="outline" aria-pressed={wide} onClick={()=>setWide(v=>!v)}>Use wide record content</Button><Button style={action} type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(v=>!v)}>Use RTL record viewport</Button><Button style={action} type="button" variant="outline" aria-pressed={compact} onClick={()=>setCompact(v=>!v)}>Use compact viewport height</Button></div>
<div className="flex flex-wrap gap-3"><Button style={action} type="button" variant="outline" onClick={()=>viewport.current?.scrollTo({top:0,behavior:'auto'})}>Go to first local records</Button><Button style={action} type="button" variant="outline" onClick={()=>viewport.current?.scrollTo({top:viewport.current.scrollHeight,behavior:'auto'})}>Go to last local records</Button></div>
<ScrollArea data-record-scroll style={{height:compact?180:260,border:'2px solid var(--color-muted-foreground)',borderRadius:'var(--radius-md)'}} dir={rtl?'rtl':'ltr'} className={state=>state.hasOverflowY?'records-overflow-y':'records-fit-y'} viewportRef={viewport} viewportProps={{role:'region','aria-label':'Local English-only record browser','data-record-viewport':true,onScroll:event=>setScroll(Math.round(event.currentTarget.scrollTop)),className:state=>state.hasOverflowX?'records-overflow-x':'records-fit-x'}}>
<div style={{padding:28,minWidth:wide?1440:undefined}}><ul className="space-y-3" data-record-list>{visible.map(record=><li key={record.id}><Button style={{...action,width:'100%',textAlign:'start',justifyContent:'flex-start'}} type="button" variant={selected===record.id?'primary':'outline'} aria-pressed={selected===record.id} onClick={()=>setSelected(record.id)}>{record.label}</Button></li>)}</ul></div>
</ScrollArea><p data-scroll-position>Actual vertical scroll position: {scroll}px.</p><p role="status" data-scroll-result>{records.find(record=>record.id===selected)?.text}</p><p>List-size, width and height controls change actual overflow. A selected record remains loaded when the shorter list excludes it; clearing/filter reconciliation is a separate task policy. The first/last actions provide alternatives to dragging the custom thumb.</p></div>;
}
Complete exported wrapper API
| Prop | Ownership |
|---|---|
| children | Content inside the automatic viewport. Use a stable content wrapper and native list/table/controls with useful names. |
| className / style / native root props | Installed Root props and HTMLDivElement ref. State-based classes now compose instead of being discarded. A label or onScroll on Root does not automatically attach to Viewport. |
| overflowEdgeThreshold | Installed Root threshold in pixels: number or per-edge xStart/xEnd/yStart/yEnd object, default 0. This changes edge-state reporting, not scroll physics or data loading. |
| viewportProps | Installed viewport props except children, including native id/role/name/description/tabIndex, handlers, style and state class callbacks. Keep a meaningful focus path when overriding defaults. |
| viewportRef | HTMLDivElement ref to the actual scroll element. Native scrollTop/scrollLeft/scrollTo and focus are available here; Root ref has a different role. |
| dir | Explicit ltr/rtl reaches DOM and installed DirectionProvider. Otherwise use the surrounding provider; dir=auto alone is not a thumb-geometry policy. |
| Composition boundary | This high-level export automatically supplies viewport, Content, both scrollbars/thumbs and corner. It does not export a custom compound-part or virtual-list API. |
| State | Installed root/viewport overflow states describe axes/edges and corner availability. They are geometry, not record coverage or completion state. |
Keyboard, pointer and focus
- Name a focusable viewport with useful context. Native arrows/Page Up/Page Down/Home/End apply when that viewport is focused, subject to browser behavior. Do not intercept editing keys from an input inside the region.
- Tab reaches actual content controls according to normal DOM order. Scrolling to a location does not automatically move focus to a record; choose focus policy intentionally.
- The installed custom thumb accepts pointer dragging and wheel input. Its track/thumb are not a replacement keyboard control or a listbox. Provide native keys and meaningful non-dragging alternatives.
- Keep keyboard focus and the current loaded record understandable when filtering/resizing removes content. Do not silently lose task state merely because a record leaves the visible list.
- Avoid trapping wheel/touch/keyboard in several nested regions. Consider when reaching the edge should continue ordinary page scrolling and test that behavior in the actual consumer.
Sizing, overflow and data lifetime
- The root needs a real size constraint; overflow:hidden on an unconstrained root does not create a useful viewport. Measure scrollHeight/clientHeight and scrollWidth/clientWidth rather than inferring overflow from CSS class names.
- Native scroll position can clamp when content shrinks. Keep record identity and loaded result separate from pixel offset, and provide recovery when a task loses its place.
- Horizontal overflow needs an actual wide-content contract. Tables often need preserved columns; ordinary prose should generally reflow instead of requiring side scrolling.
- The wrapper renders all its children. Virtualization, server pagination, loading/error recovery and source coverage need their own task model and acceptance evidence.
- Overlay tracks can cover content. Reserve padding or choose an appropriate layout; verify both-axis corner, RTL, thumb bounds and focused-control visibility.
States and recovery checks
| State | Verify |
|---|---|
| No overflow | Content remains readable; unnecessary authored bars hide and the viewport is not an unexplained tab stop. |
| Vertical / horizontal / both | Actual ranges and reported axis/edge state agree. Track movement corresponds to native offsets and corner dimensions. |
| Focused / dragged | Visible viewport focus and actual scroll change. Drag is not the only way to reach content. |
| Selected / filtered / resized | Real result remains tied to record identity; selected content disappearing from the list is explained/reconciled by the product. |
| Loading / empty / failed data | A scroll region alone is not a loading result. Provide real request coverage, status, retry and empty recovery outside blocked controls. |
| RTL / zoom / forced colors | Direction context, layout and physical tracks agree; important content and focus cues remain accessible. |
Migration and verification
The old guide called custom scrollbars purely visual, although their installed parts implement pointer/wheel interaction. The wrapper previously exposed only Root props/ref, making viewport naming and native scroll operations awkward. New viewport access keeps these contracts explicit.
- Run test:scroll-area, checked-source/token freshness, lint, shared UI/styleguide types, production build and the complete route/fragment crawl. Verify real native offsets, keys, first/last controls, record outcomes, dynamic overflow, viewport props/refs, direction, geometry, contrast and narrow layout.
- Spoken assistive-technology, hardware touch/trackpads, platform scroll physics, huge/virtualized datasets and remote paging need separately scoped evidence. Authored geometry checks do not certify all those contexts.