MobTranslate styleguide

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

PartContract
RootStyled installed div/ref with relative positioning, clipped outer overflow, shrinkable bounded width and caller-defined height/width. Root is not the scrolling element.
ViewportActual native scrolling div. New viewportProps/viewportRef reach its name, focus, handlers and scroll methods. Installed overflow logic makes the viewport focusable when needed.
ContentAutomatic installed Content observes size changes; caller supplies meaningful list/table structure within it. No data virtualization or item-count reduction is supplied.
Tracks / thumbsAutomatic 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 / cornerExplicit ltr/rtl configures both DOM and installed direction context. Vertical track uses logical inline-end. Automatic Corner handles both-axis intersection.
Focus / content paddingViewport 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.

tsx
'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

PropOwnership
childrenContent inside the automatic viewport. Use a stable content wrapper and native list/table/controls with useful names.
className / style / native root propsInstalled 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.
overflowEdgeThresholdInstalled 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.
viewportPropsInstalled 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.
viewportRefHTMLDivElement ref to the actual scroll element. Native scrollTop/scrollLeft/scrollTo and focus are available here; Root ref has a different role.
dirExplicit ltr/rtl reaches DOM and installed DirectionProvider. Otherwise use the surrounding provider; dir=auto alone is not a thumb-geometry policy.
Composition boundaryThis high-level export automatically supplies viewport, Content, both scrollbars/thumbs and corner. It does not export a custom compound-part or virtual-list API.
StateInstalled 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

StateVerify
No overflowContent remains readable; unnecessary authored bars hide and the viewport is not an unexplained tab stop.
Vertical / horizontal / bothActual ranges and reported axis/edge state agree. Track movement corresponds to native offsets and corner dimensions.
Focused / draggedVisible viewport focus and actual scroll change. Drag is not the only way to reach content.
Selected / filtered / resizedReal result remains tied to record identity; selected content disappearing from the list is explained/reconciled by the product.
Loading / empty / failed dataA scroll region alone is not a loading result. Provide real request coverage, status, retry and empty recovery outside blocked controls.
RTL / zoom / forced colorsDirection 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.