Scroll View

A container control aligned with the WinUI 3 ScrollView: when content exceeds the viewport it scrolls, pans, and zooms, with scrollbars styled like WinUI 3's overlay style (a 2px thin thumb that appears once the pointer enters the container; when the pointer moves onto the scrollbar the thumb thickens to 6px, the pill-shaped track expands along with the step buttons at both ends, then collapses when the pointer leaves, and the whole thing fades out after 2s without interaction).

Basic usage (vertical)

By default content-orientation="vertical"; the wheel / touch panning / dragging the scrollbar can all scroll.

Touch panning is owned exclusively by native Touch Events (non-passive listeners that call preventDefault on touchmove), handled separately from pen; therefore native scroll containers inside the content (e.g. code blocks) cannot be scrolled by touch — an existing limitation of the touch model.

Row 1 — native Windows 11 scrolling experience
Row 2 — native Windows 11 scrolling experience
Row 3 — native Windows 11 scrolling experience
Row 4 — native Windows 11 scrolling experience
Row 5 — native Windows 11 scrolling experience
Row 6 — native Windows 11 scrolling experience
Row 7 — native Windows 11 scrolling experience
Row 8 — native Windows 11 scrolling experience
Row 9 — native Windows 11 scrolling experience
Row 10 — native Windows 11 scrolling experience
Row 11 — native Windows 11 scrolling experience
Row 12 — native Windows 11 scrolling experience
Row 13 — native Windows 11 scrolling experience
Row 14 — native Windows 11 scrolling experience
Row 15 — native Windows 11 scrolling experience
Row 16 — native Windows 11 scrolling experience
Row 17 — native Windows 11 scrolling experience
Row 18 — native Windows 11 scrolling experience
Basic usage (vertical)

Horizontal scrolling

With content-orientation="horizontal", the content's height is constrained to the viewport while its width grows freely. Scroll horizontally with Shift + wheel or touch.

Card 1
Card 2
Card 3
Card 4
Card 5
Card 6
Card 7
Card 8
Card 9
Card 10
Horizontal scrolling

Bidirectional scrolling

With content-orientation="both", the content is unconstrained in both horizontal and vertical directions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Bidirectional scrolling

Nested scrolling

An inner ScrollView sits within the outer content. Mouse-wheel ownership aligns with WinUI 3: when the pointer stays over the inner scroll view, the inner one exclusively owns the wheel, so even if the inner one has already scrolled to its limit the outer one will not scroll along; only when the pointer is moved to outer content outside the inner one does the wheel go to the outer view (touch is owned exclusively by native touch events, pen is exclusively captured by the pointer).

Outer content 1 · the wheel scrolls the outer view only when the pointer is here
Outer content 2 · the wheel scrolls the outer view only when the pointer is here
Outer content 3 · the wheel scrolls the outer view only when the pointer is here
Outer content 4 · the wheel scrolls the outer view only when the pointer is here
Inner A · row 1 (the outer view stays still at the end)
Inner A · row 2 (the outer view stays still at the end)
Inner A · row 3 (the outer view stays still at the end)
Inner A · row 4 (the outer view stays still at the end)
Inner A · row 5 (the outer view stays still at the end)
Inner A · row 6 (the outer view stays still at the end)
Inner A · row 7 (the outer view stays still at the end)
Inner A · row 8 (the outer view stays still at the end)
Inner A · row 9 (the outer view stays still at the end)
Inner A · row 10 (the outer view stays still at the end)
Inner B · row 1 (the outer view stays still at the end)
Inner B · row 2 (the outer view stays still at the end)
Inner B · row 3 (the outer view stays still at the end)
Inner B · row 4 (the outer view stays still at the end)
Inner B · row 5 (the outer view stays still at the end)
Inner B · row 6 (the outer view stays still at the end)
Inner B · row 7 (the outer view stays still at the end)
Inner B · row 8 (the outer view stays still at the end)
Inner B · row 9 (the outer view stays still at the end)
Inner B · row 10 (the outer view stays still at the end)
Outer tail content 1
Outer tail content 2
Outer tail content 3
Outer tail content 4
Outer tail content 5
Outer tail content 6
Nested scrolling

Focus scrolling

When focus (Tab or programmatic focus()) enters an element inside the content, the element is automatically scrolled into view — aligned with WinUI BringIntoViewOnFocusChange (enabled by default), so keyboard users never leave focus outside the viewport; no animation occurs if the element is already in view. That scroll also fires the bring-into-view event, and the target offset can be cancelled or rewritten in the callback.

When the container gains focus (or focus is inside the content), the arrow keys, PageUp / PageDown, Home / End scroll the view; when an input / textarea / select / contenteditable is hit, the keys are handed back to the input control.

Zoom

With zoom-mode="enabled": Ctrl / Cmd + wheel or pinch-to-zoom, with the pointer position as the zoom center. Aligned with the WinUI min-zoom-factor / max-zoom-factor constraints.

Ctrl + wheel to zoom
Zoom

Scrollbar visibility

vertical-scroll-bar-visibility supports auto (default, overlay) / visible (always shown) / hidden (still scrollable).

auto (default, overlay)

visible (always shown)

hidden (still scrollable)

Scrollbar visibility

Programmatic API

Aligned with the WinUI methods: scrollTo / scrollBy / zoomTo / zoomBy / addScrollVelocity / addZoomVelocity / bringIntoView (supporting animation and correlation IDs), plus the read-only properties horizontalOffset / verticalOffset / zoomFactor / extentWidth / extentHeight / viewportWidth / viewportHeight / scrollableWidth / scrollableHeight / state / currentAnchor. Full signatures are in the 「Methods」 and 「Readonly properties」 tables below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
(shown after interaction)
Programmatic API

Events

view-changed / extent-changed / state-changed / scroll-animation-starting / scroll-completed / zoom-animation-starting / zoom-completed / anchor-requested / bring-into-view, with payloads in the 「Events」 table below. These are component events (@event); the DOM command events meant for page automation are in 「Agent / Automation access」, their names carry the fluere: prefix and they are dispatched on the root element.

Events

Agent / Automation access

This component expresses its offset through a content transform and its presenter is overflow: clip (not a native scroll container), so the native scrolling semantics of browsers and automation tools do not apply here: scrollTop is always 0, and scrollBy / scrollIntoView / scrollIntoViewIfNeeded can find no scrollable ancestor (native auto-scrolling before Playwright clicks an element outside the viewport also fails, which is the pitfall documented in style-spec §7). For this the component provides two DOM-based paths, neither of which requires touching the component instance:

  • Read-only reflection: the root element always carries a set of state attributes for determining 「whether it moved / how much is left」;
  • Command events: dispatching fluere:* events on the root element drives the scrolling, and receipts are received once applied.

Read-only reflection attributes

AttributeDescription
data-scroll-xCurrent horizontal offset (pixels, rounded)
data-scroll-yCurrent vertical offset (pixels, rounded)
data-scroll-max-xMaximum scrollable width (content × zoom − viewport, rounded)
data-scroll-max-yMaximum scrollable height, rounded
data-zoom-factorCurrent zoom factor
data-scroll-state'idle' | 'interaction' | 'inertia' | 'animation'

Once rounded, equality comparisons can safely determine 「whether it reached the end」 (data-scroll-y === data-scroll-max-y). These attributes are rewritten frame by frame when the offset changes, do not match them in CSS (otherwise style recalculation is triggered on every frame during animation; a contract test in the repo guards this rule).

Command events

Dispatch on the component root element (.fui-scrollview), with bubbles: true, so they can also be attached at the page level.

Event (DOM event)Payload (event.detail)Description
fluere:scroll-to{ x?, y?, animationMode? }Scroll to an absolute offset; omitted parts keep their current value
fluere:scroll-by{ x?, y?, animationMode? }Scroll by a delta; omitted parts are treated as 0
fluere:bring-into-view{ element?, selector?, margin? }Scroll an element inside the content into view (element takes precedence)
fluere:scroll-settled{ x, y, zoomFactor } (outbound)Dispatched by the component after a command lands, for waiting on a stable frame

An invalid payload (a non-finite number, an unresolvable target) is a no-op and does not throw; bring-into-view only accepts elements inside the content. Passing animationMode as 'disabled' skips the animation, which suits deterministic positioning before a screenshot; setting agentCommands to false disables the command events (read-only reflection is kept).

const el = document.querySelector('.fui-scrollview')

// 1. Read state first: how much more needs to scroll?
const remaining = Number(el.dataset.scrollMaxY) - Number(el.dataset.scrollY)

// 2. Dispatch the command (bubbles lets page-level listeners receive it too)
el.dispatchEvent(
  new CustomEvent('fluere:scroll-by', { detail: { y: remaining, animationMode: 'disabled' } }),
)

// 3. Wait for the receipt before screenshotting / asserting
await new Promise((resolve) =>
  el.addEventListener('fluere:scroll-settled', resolve, { once: true }),
)
Row 1
Row 2
Row 3
Row 4
Row 5
Row 6
Row 7
Row 8
Row 9
Row 10
Row 11
Row 12
Row 13
Row 14
Row 15
Row 16
Row 17
Row 18
Row 19
Row 20
Row 21
Row 22
Row 23
Row 24
Row 25
Row 26
Row 27
Row 28
Row 29
Row 30
(reading data-*)
Agent interaction surface

Native semantics that still do not apply: the component is not a native scroll container, so el.scrollTop, el.scrollBy(), el.scrollIntoView(), CDP DOM.scrollIntoViewIfNeeded, the browser's 「find in page」 and #:~:text= text fragment anchoring still do not work — use the command events above instead. There is another existing path: when focus enters an element in the content, the component automatically scrolls it into view per BringIntoViewOnFocusChange (aligned with WinUI, see bringIntoView in the 「Methods」 table).

API

Props

PropTypeDefaultDescription
contentOrientation'vertical' | 'horizontal' | 'none' | 'both''vertical'Content layout direction, determines how the content is constrained by the viewport
horizontalScrollMode'enabled' | 'disabled''enabled'Whether the user may scroll horizontally
verticalScrollMode'enabled' | 'disabled''enabled'Whether the user may scroll vertically
horizontalScrollBarVisibility'auto' | 'visible' | 'hidden''auto'Horizontal scrollbar display strategy (auto is overlay, shown only when the pointer enters the container)
verticalScrollBarVisibility'auto' | 'visible' | 'hidden''auto'Vertical scrollbar display strategy
horizontalScrollChainMode'auto' | 'always' | 'never''auto'Chaining of horizontal scroll; see the 「Wheel ownership」 note below
verticalScrollChainMode'auto' | 'always' | 'never''auto'Chaining of vertical scroll, same semantics as above
horizontalScrollRailMode'enabled' | 'disabled''enabled'Horizontal touch panning rail
verticalScrollRailMode'enabled' | 'disabled''enabled'Vertical touch panning rail
zoomMode'enabled' | 'disabled''disabled'Whether the user may zoom
zoomChainMode'auto' | 'always' | 'never''auto'Chaining of zoom
ignoredInputKindsScrollingInputKinds | ScrollingInputKinds[][]Input kinds to ignore; a single value or an array can be passed; such inputs are not responded to
minZoomFactornumber0.1Minimum zoom factor
maxZoomFactornumber10Maximum zoom factor
horizontalAnchorRationumberNaNHorizontal anchor ratio (0–1); NaN disables horizontal anchoring; 0 keeps the content's left edge against the viewport's left edge, 1 keeps the right edge against the right edge
verticalAnchorRationumberNaNVertical anchor ratio (0–1); NaN disables vertical anchoring
backgroundstring—Background color of the content area
tabIndexnumber0Keyboard focusable (Arrow / PageUp / Home etc. arrow keys scroll)
labelstring—Accessible name; once set the root element carries role="region", letting screen readers and the accessibility-tree Agent recognize the scroll area
agentCommandsbooleantrueWhether to respond to fluere:* DOM command events (see 「Agent / Automation access」); the read-only state reflection is always kept

Events

EventPayloadDescription
view-changed—The viewport offset or zoom changed (fires continuously during interaction)
extent-changed—The content area size changed
state-changed'idle' | 'interaction' | 'inertia' | 'animation'The interaction state changed
scroll-animation-startingScrollingScrollAnimationStartingEventArgsA scroll animation is about to start; the target offset can be rewritten
scroll-completedScrollingScrollCompletedEventArgsA scroll ended (includes correlationId)
zoom-animation-startingScrollingZoomAnimationStartingEventArgsA zoom animation is about to start
zoom-completedScrollingZoomCompletedEventArgsA zoom ended (includes correlationId)
anchor-requestedScrollingAnchorRequestedEventArgsAn anchor is requested (a custom anchor element can be returned)
bring-into-viewScrollingBringingIntoViewEventArgsAbout to scroll an element into view; the target offset can be rewritten

Methods

Called through a template ref; each returns the correlationId of this operation, for matching against the payloads of scroll-completed / zoom-completed.

MethodSignatureDescription
scrollTo(horizontalOffset: number, verticalOffset: number, options?: ScrollingScrollOptions) => numberScroll to an absolute offset
scrollBy(horizontalOffsetDelta: number, verticalOffsetDelta: number, options?: ScrollingScrollOptions) => numberScroll by a delta
zoomTo(zoom: number, centerPoint?: { x, y } | null, options?: ScrollingZoomOptions) => numberZoom to a specified factor; centerPoint is the zoom center (defaults to the viewport center)
zoomBy(zoomFactorDelta: number, centerPoint?: { x, y } | null, options?: ScrollingZoomOptions) => numberZoom by a delta
addScrollVelocity(offsetsVelocity: { x, y }, inertiaDecayRate?: { x, y } | null) => numberAppend scroll velocity (inertial scrolling)
addZoomVelocity(zoomFactorVelocity: number, centerPoint?: { x, y } | null, inertiaDecayRate?: number | null) => numberAppend zoom velocity
bringIntoView(element: HTMLElement, options?: { margin?: number }) => numberScroll an element into view (auto-triggered by the focused element)
registerAnchorCandidate(element: HTMLElement) => voidRegister an anchor candidate (or mark with data-can-scroll-anchor)
unregisterAnchorCandidate(element: HTMLElement) => voidUnregister an anchor candidate

ScrollingScrollOptions / ScrollingZoomOptions take 'disabled' \| 'enabled' \| 'auto' for animationMode (default 'auto'), plus a placeholding snapPointsMode.

Readonly properties

Readonly propertyTypeDescription
horizontalOffsetnumberCurrent horizontal offset
verticalOffsetnumberCurrent vertical offset
zoomFactornumberCurrent zoom factor
extentWidthnumberContent area width
extentHeightnumberContent area height
viewportWidthnumberViewport width
viewportHeightnumberViewport height
scrollableWidthnumberScrollable width (content − viewport)
scrollableHeightnumberScrollable height (content − viewport)
state'idle' | 'interaction' | 'inertia' | 'animation'Current interaction state
currentAnchorHTMLElement | nullThe currently active anchor element

Slots

SlotDescription
defaultScrollable content

Wheel ownership (aligned with WinUI 3): when the pointer is inside this component and that direction is actually scrollable, this wheel is owned exclusively by the component — even at the scroll limit it will not scroll the outer ScrollView; when horizontal/verticalScrollChainMode is auto / never, reaching the limit swallows the wheel (equivalent to overscroll-behavior: contain), and only when it is always is the unconsumed remaining delta explicitly handed to the outer view. When the current direction has no scrollable content, or that direction is disabled, the wheel is not owned and bubbles to the outer view. Touch is owned exclusively by native touch events and pen is exclusively captured by the pointer; neither goes through this determination.

v0.5.0-alpha.1