Skip to content

Position preservation and layout changes

After initial restoration, content and layout can shift dynamically due to asynchronous data insertions, container resizing, or sticky header height changes. ngx-magic-scroll implements an anchor-relative pixel displacement policy that preserves the active section’s pixel displacement, preventing disruptive jumps back to section headings during automatic reflows.

Cause Target Displacement policy Default behavior
Initial ready render Fragment from URL snapshot Aligned to heading (displacement = 0) Restore once, instantly (unless user cancelled via interaction)
Explicit scrollTo() Requested anchor Aligned to heading (displacement = 0) Scroll smoothly
External router fragment change Route destination anchor Aligned to heading (displacement = 0) Restore instantly
Later ready source change Current live visible section Preserves saved pixel displacement (savedDisplacement) Correct instantly without resetting to section top
Window resize or measured header-height change Current live visible section Preserves saved pixel displacement (savedDisplacement) Realign instantly to maintain section pixel offset
Region content reflow (layout expansion/image) Current live visible section Preserves saved pixel displacement (savedDisplacement) Opt-in (preserveOnLayoutChange: true); coalesced via rAF

The distinction between initial URL fragments, live visible sections, and intentional user navigation ensures seamless reading continuity. When reading halfway through a 1200px card, layout reflows preserve the section’s relative pixel position rather than resetting to the card header.

While scrolling, the active visible section geometry and anchor-relative pixel displacement are captured in real time, whereas updates to the browser’s URL router are debounced by debounceTime (default 500ms) to avoid cluttering browser history during continuous motion. When automatic realignment or data updates occur during this debounce interval, ngx-magic-scroll preserves the real-time visible section and displacement rather than falling back to a stale URL anchor.

sequenceDiagram
    autonumber
    actor User
    participant Viewport as Scroll Container
    participant Directive as MagicScrollDirective
    participant Provider as NearestAnchorProvider
    participant Service as ScrollService

    Note over User,Directive: User scrolls into long section
    User->>Viewport: Scroll to midpoint of long card
    Viewport->>Directive: onScroll event
    Directive->>Provider: calculateNearestAnchor()
    Provider-->>Directive: liveAnchor ('card-1')
    Directive->>Directive: Capture savedDisplacement = anchorRect.top - topBoundary

    Note over Viewport,Directive: Automatic layout reflow occurs
    alt Data prepended / Container resize / Header resize
        Viewport->>Directive: Data update / ResizeObserver trigger
        Directive->>Directive: preserve() called
        Directive->>Directive: Compute delta = (currentAnchorRect.top - topBoundary) - savedDisplacement
        Directive->>Service: Adjust scrollable.scrollTop by delta (clamped to bounds)
        Service->>Viewport: scrollable.scrollTo(targetScrollTop, instant)
        Note over User,Viewport: Section displacement preserved!
    end

1. Displacement capture during user scrolling

Section titled “1. Displacement capture during user scrolling”

As the user scrolls through the container, onScroll computes the reading boundary and measures the active section’s top offset:

topBoundary = containerTop + measuredHeaderHeight + headerOffset
savedDisplacement = activeAnchorElement.getBoundingClientRect().top - topBoundary

For example, when reading 400px deep into a card, savedDisplacement is -400px.

2. Delta calculation and native scroll anchoring compatibility

Section titled “2. Delta calculation and native scroll anchoring compatibility”

When automatic reflow occurs (e.g. items prepended above the section, or sticky header expanded):

currentDisplacement = currentAnchorElement.getBoundingClientRect().top - topBoundary
delta = currentDisplacement - savedDisplacement
targetScrollTop = clamp(currentScrollTop + delta, 0, maxScrollTop)
  • Without browser scroll anchoring: If content is inserted above the section, currentDisplacement changes by the height of the prepended items. delta equals the shift and scrollTop is adjusted instantly, keeping the section visually locked under the reader’s view.
  • With native browser scroll anchoring (overflow-anchor: auto): If the browser has already compensated scrollTop for inserted content, currentDisplacement remains equal to savedDisplacement. delta computes to 0, inherently preventing double-compensation without browser-sniffing heuristics.
  • Intra-section limitation: Anchor-relative displacement preserves the active section anchor’s pixel offset relative to the reading boundary (savedDisplacement). It does not track intra-section elements or percentage progress through a section. Consequently, when text wraps or content changes inside the active section itself, the exact paragraph or sentence being read may still move; retaining a specific paragraph across internal changes requires finer-grained child anchors.

Explicit programmatic calls (scrollTo('card-2')) and external router fragment navigations reset savedDisplacement to 0. Intentional navigation always aligns the destination section’s heading cleanly to the configured top offset.

If the active section element is removed from the DOM during a data change or layout update, preserve() gracefully skips realignment and maintains the current viewport position without arbitrary jumps.

Region content reflow with preserveOnLayoutChange

Section titled “Region content reflow with preserveOnLayoutChange”

In dynamic web applications, presentation-level elements within a scroll region frequently change dimensions without any updates to the fetched data source (scrollSource). Common examples include:

  • Expanding an accordion or detail disclosure panel above the reader’s active section.
  • Asynchronous image loads settling to their natural height without pre-set aspect ratios.
  • Conditional validation messages or interactive preview cards appearing dynamically.

When preserveOnLayoutChange: true is enabled, MagicScrollDirective attaches a region-scoped ResizeObserver to the host element. Layout shifts are coalesced per animation frame (requestAnimationFrame) to eliminate layout thrashing and feedback loops.

graph TD
    A[Region Content Reflow / Image Loaded / Accordion Expanded] -->|ResizeObserver Callback| B{preserveOnLayoutChange Enabled?}
    B -->|No| C[Ignore Reflow]
    B -->|Yes| D{Initialized & Not Interrupted?}
    D -->|No / User Scrolling| E[Suppress Realignment]
    D -->|Yes| F{rAF Already Queued?}
    F -->|Yes| G[Coalesce into Existing Frame]
    F -->|No| H[Queue requestAnimationFrame]
    H --> I[Execute preserve()]
    I --> J[Calculate Delta against liveAnchor & savedDisplacement]
    J --> K{Delta === 0 or Anchored by Browser?}
    K -->|Yes| L[No-op: Native overflow-anchor already compensated]
    K -->|No| M[Scrollable.scrollTo: Adjust scrollTop instantly]

Supported scenarios vs unsupported modifications

Section titled “Supported scenarios vs unsupported modifications”
  • Supported (Dimension Changes): Any internal DOM expansion or contraction that changes box dimensions (such as toggling accordions, inserting child cards, or loading images) alters the region’s layout and triggers instant, seamless realignment.
  • Unsupported (Position-Only Shifts): Pure CSS coordinate changes (such as transform: translateY(...) or CSS top transitions) that do not alter the box geometry or layout dimensions of elements in the DOM cannot be observed by ResizeObserver.
  • User Gesture Priority: If the reader is actively scrolling via touch gestures, wheel events, or navigation keys, automated layout realignments yield immediately to user navigation.

The effective top offset boundary is:

measured header height + headerOffset

With no configured or matching header, the measured height is zero and headerOffset supplies the entire offset. With a header, it adds a gap below that header. Use a headerSelector that uniquely identifies the intended element; its height is measured and observed automatically via ResizeObserver.

offset() exposes the sum, and headerHeight() exposes the measurement alone. A sticky companion panel can use the same sum for its CSS top so navigation and scroll alignment share one boundary.

A per-call topOffset replaces the entire effective offset for that action. It is not an additional gap. Viewport boundary clamping ensures scroll targets are bounded within [0, scrollHeight - clientHeight].

Use restoreOnDataChange: false when data updates should let the viewport move naturally without anchor preservation. Use preserveOnResize: false when automatic realignment during resizing or header changes is unwanted. Disabling layout corrections still leaves the measured header height and effective offset available.

Initial restoration and explicit interactions have separate behavior defaults: behavior.restoration is instant, and behavior.interaction is smooth. Automatic data and layout corrections remain instant.

Provider defaults are inherited through Angular’s injector hierarchy. Region scrollOptions override those defaults, and supported per-call options override the region for one scrollTo() action.