Skip to content
ArcadiaDesign

TSX

Size the root, with a height or a max-height, and the viewport fills it. Scrolling is native. The 4px steel scrollbar overlays the content, so it takes no width; it shows while the area is hovered or scrolling and fades 600ms after. The edge fades grow with the hidden distance, from 0 to 24px, so a list scrolled to the top has no fade there. Scroll the list above to watch both ends change.

A transcript passes viewportRef and contentRef to useStickToBottom and sets anchoring={false}, so the hook owns pinning to the newest message.

Scrolls the app starts

Reader scrolling is never smoothed or scripted.

springScroll(el, to) is a critically damped spring (ω 26, ζ 1): half-way in about 65ms, settled in about 300ms, with no overshoot. It re-aims every frame when to is a function, so a jump lands on a bottom that keeps growing; beyond 1.5 viewports it fast-forwards; and it stops the moment the reader touches anything, while a trackpad's momentum tail from before it started neither stops it nor fights it. useStickToBottom's jumpToBottom() runs on it and lands pinned. Under reduced motion it is one instant write.

useScrollKeys(ref) puts PageUp, PageDown, Home, End and Space on the spring while focus is in the scroller (or on the page, with whenBodyFocused); a press while it runs steps from its target, so holding PageDown never falls behind. revealInView(viewport, row) brings a row in by its nearest edge with two rows of margin, and does nothing when it is already in view.

Keys on the spring

Focus the list, then press PageDown, End or Home.

Only a scroll the app starts moves on a spring: a jump to the latest message, PageUp, PageDown, Home and End in a scroller, revealing the active row.

Examples

Horizontal

orientation and fade per axis: long code lines, wide tables.

Under sticky chrome

A sticky label sets --scroll-fade-inset-top to its height: rows fade out at its lower edge instead of being cut by it.

A see-through bar laid over the scroller also sets --scroll-fade-inset-alpha to 0, so no rows show through it.

rootStyle takes where the scroller sits, the longest edge fade, the chrome the top fade starts under and the overlay scrollbar's size and inset.

--scroll-fade-size
TokenRef<Space>Default var(--space-6)
The longest edge fade (24).
--scrollbar-size
TokenRef<Measure>Default var(--scrollbar-size-visible)
The overlay scrollbar's thickness.
--scrollbar-inset
TokenRef<Space>Default var(--space-0-5)
The scrollbar's distance from the edges.
--scroll-radius
TokenRef<Radius> | Derived | "0px"Default 0px
The corner of the box it sits in: the bar's cap stays concentric with it (R4). Default 0px.
--scroll-fade-inset-top
TokenRef<Measure> | TokenRef<Control> | Derived | "0px"Default 0px
Chrome over the top edge: the top fade starts below it. Default 0px.
--scroll-fade-inset-bottom
TokenRef<Measure> | TokenRef<Control> | Derived | "0px"Default 0px
The same on the bottom edge (a floating button). Default 0px.
--scroll-fade-inset-alpha
"0" | "1"Default 1
Past an inset: 1 paints (a sticky label inside), 0 hides rows under see-through chrome. Default 1.
--scroll-padding-block
TokenRef<Space> | "0px"Default 0px
A row scrolled into view stops this far from the edges. Default 0px.
--scroll-padding-top
TokenRef<Measure> | Derived | "0px"Default var(--scroll-padding-block)
The same at the top alone. Default --scroll-padding-block.
--scrollbar-offset-top
TokenRef<Measure> | Derived | "0px"Default 0px
Chrome over the top edge: the vertical bar starts this much lower. Default 0px.

The viewport is a normal scroller: wheel, touch and keyboard scrolling all work, and keyboard focus draws an inset ring on the root. Pass viewportProps for a role or a label, such as role="log" on a transcript, so the region is announced for what it is.

↓↑
Scroll a line
PageDownPageUp
Scroll a page
HomeEnd
Jump to the start or the end

ScrollArea

fade
"y" | "x" | "both" | "top" | "bottom" | "left" | "right" | falseDefault false
Mask the edges that have hidden content; the fade grows with the hidden distance (0 → 24 px).
orientation
"vertical" | "horizontal" | "both"Default "vertical"
Which axes scroll (and get an overlay scrollbar).
anchoring
booleanDefault true
false: overflow-anchor: none, for scrollers whose pinning useStickToBottom owns (the thread).
stickyBar
booleanDefault false
Horizontal: the bar sticks to the bottom of the scroller around it while the area shows (a long diff).
timeline
`--${string}`
Names the viewport's block scroll timeline, for chrome that follows the scroll.
viewportRef
Ref<HTMLDivElement>
The scrolling element (pass it to useStickToBottom).
contentRef
Ref<HTMLDivElement>
The content wrapper (the second useStickToBottom ref).
viewportProps
Omit<ComponentProps<"div">, "ref" | "children">
Extra props for the viewport, e.g. role="log", aria-label.
rootStyle
RootStyle<ScrollAreaKnobs>
Typed StyleX override: where the scroller sits (layout) and its knobs.
className
string
A product's hook class for its own rules (being retired: new code sets rootStyle).

Rest props go to <div> props.

omega
numberDefault 26
Natural frequency in rad/s.
onFrame
(top: number) => void
After every write, with the new scrollTop.
onEnd
(reason: SpringScrollEnd) => void
Once, with why it stopped.

whenBodyFocused
boolean
Also take the keys while nothing has focus (<body>): the app's main scroller.
end
() => void
End jumps to the bottom through this (a transcript's jumpToBottom(true), which re-pins).
onKey
() => void
Called before a key moves the scroller, so a stick-to-bottom hook reads it as the reader's.