You know the feeling: you click a "Back to top" control and nothing happens… or it jumps to a weird spot… or it animates in a way that makes you slightly nauseous. Scrolling is deceptively simple until you ship a real interface with sticky headers, lazy-loaded sections, route transitions, and content that grows after images decode.
When I need to move the viewport to a specific location in the document, window.scrollTo() is still one of the cleanest primitives in the platform. It‘s not flashy, but it‘s reliable and it‘s the foundation under a lot of "polished" UX.
I‘ll walk you through how window.scrollTo() really works (coordinates, timing, and browser behavior), the modern options-object form with smooth scrolling, and the patterns I reach for in production: offsetting for sticky headers, scrolling after layout changes, respecting reduced-motion settings, and avoiding performance traps. By the end, you‘ll be able to add intentional scrolling that feels predictable instead of mysterious.
The mental model: you‘re setting the viewport‘s top-left corner
window.scrollTo() scrolls the document so that the viewport‘s upper-left corner aligns with a pair of coordinates in the page.
x(left): pixels from the left edge of the documenty(top): pixels from the top edge of the document
The classic signature looks like this:
window.scrollTo(x, y);
That‘s it. No return value, no callback.
A few details I keep in my head because they prevent subtle bugs:
1) Coordinates are in CSS pixels (not device pixels). On a high-DPI screen, the browser handles the scaling.
2) The scroll container is the window‘s document, not a random scrolling
element.scrollTo() on that element.
3) The maximum scroll position depends on layout. If the page content changes height after you call scrollTo(), you can get clamping (you asked for y=5000, but the document only scrolls to y=3800).
4) The origin is always the document‘s top-left, even if you have a sticky header or a fixed toolbar. Those overlays don‘t change the coordinate system; they just cover part of the viewport.
A quick way to sanity-check the coordinate space is to log window.scrollX / window.scrollY while you scroll manually and compare them to what you pass into scrollTo().
Two API forms you should know: numeric vs options-object
In modern code, I almost always use the options-object form because it‘s more readable and enables smooth scrolling.
1) Numeric form (classic)
window.scrollTo(0, 0); // jump to top-left
window.scrollTo(0, 600); // jump down 600px
window.scrollTo(250, 110); // jump both axes
This is a direct jump. It can feel abrupt, but it‘s also fast and deterministic.
2) Options-object form (modern)
window.scrollTo({
top: 600,
left: 0,
behavior: ‘smooth‘,
});
top: vertical target (likey)left: horizontal target (likex)behavior:‘auto‘(default) or‘smooth‘
I prefer this form because top and left make intent obvious, especially months later when you‘re reading your own code.
Traditional vs modern patterns (what I recommend in 2026)
Traditional approach
—
window.scrollTo(0, 0)
window.scrollTo({ top: 0, behavior: motionOk ? ‘smooth‘ : ‘auto‘ }) scrollTo(0, 0) in random lifecycle hook
requestAnimationFrame + scrollTo, or framework route hook + layout-safe timing element.offsetTop math
element.scrollIntoView() when possible, scrollTo when you need offsets window.scrollTo(...) (wrong target)
panel.scrollTo({ top, behavior }) If you only remember one thing from this section: use the options-object form by default, and treat smooth scrolling as a preference-sensitive enhancement.
A complete, runnable example: a back-to-top control with reduced-motion support
Here‘s a single-file HTML example you can paste into a blank file and run. It shows a "Back to top" button only after you scroll, and it respects the user‘s reduced-motion setting.
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
line-height: 1.5;
margin: 0;
}
header {
position: sticky;
top: 0;
background: #111827;
color: white;
padding: 12px 16px;
z-index: 10;
}
main {
padding: 16px;
max-width: 900px;
}
.spacer {
height: 2200px;
background: linear-gradient(to bottom, rgba(59,130,246,0.12), transparent);
border-radius: 12px;
}
#backToTop {
position: fixed;
right: 16px;
bottom: 16px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.15);
background: white;
box-shadow: 0 8px 22px rgba(0,0,0,0.12);
cursor: pointer;
display: none;
}
#backToTop:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
Sticky header (covers content, does not change scroll coordinates)
window.scrollTo()
Scroll down. The button appears after you pass 400px.
Clicking it scrolls back to the top.
Bottom area.
Back to top
const backToTopButton = document.getElementById(‘backToTop‘);
const prefersReducedMotion = window.matchMedia(‘(prefers-reduced-motion: reduce)‘);
function shouldShowButton() {
return window.scrollY > 400;
}
function render() {
backToTopButton.style.display = shouldShowButton() ? ‘inline-block‘ : ‘none‘;
}
window.addEventListener(‘scroll‘, render, { passive: true });
render();
backToTopButton.addEventListener(‘click‘, () => {
const behavior = prefersReducedMotion.matches ? ‘auto‘ : ‘smooth‘;
window.scrollTo({ top: 0, left: 0, behavior });
});
// If the user changes the motion preference while the page is open.
prefersReducedMotion.addEventListener(‘change‘, () => {
// No action needed here, but having the listener reminds me this can change.
});
Why I like this pattern:
- It‘s accessible: visible focus state, clear label, respects reduced motion.
- It‘s predictable:
scrollTo({ top: 0 })always goes to the top, no fragile math. - It avoids heavy work in the scroll handler (just a display toggle).
Getting offsets right with sticky headers (the part that trips teams up)
A classic UI problem: you scroll to a section, but a sticky header hides the title. The instinct is to subtract the header height and call scrollTo(). That works, but it‘s easy to get wrong when the header height changes across breakpoints.
Here‘s the approach I recommend:
1) Use CSS when you can.
2) Use scrollTo math only when you must.
Prefer CSS: scroll-margin-top for section targets
If you‘re scrolling to a particular element (like a heading), you can add a margin that only affects scroll positioning:
:root {
–sticky-header-height: 56px;
}
section[id] {
scroll-margin-top: calc(var(–sticky-header-height) + 12px);
}
Now, any API that scrolls an element into view (including element.scrollIntoView() and anchor navigation) naturally leaves space for the sticky header.
When you need scrollTo: compute target with getBoundingClientRect
I use this when I need custom timing, custom easing (rare), or when I‘m mixing scroll with other effects.
function scrollToElementWithOffset(element, offsetPx) {
const rect = element.getBoundingClientRect();
const absoluteTop = rect.top + window.scrollY;
window.scrollTo({
top: Math.max(absoluteTop – offsetPx, 0),
behavior: ‘smooth‘,
});
}
A few guardrails I like:
- Clamp at
0so you don‘t request negative positions. - Use
rect.top + window.scrollYrather thanoffsetTop.offsetTopcan surprise you with nested offset parents.
Timing matters: scrolling before layout is ready gives you wrong coordinates
The single biggest "why is scrollTo broken?" bug I see is actually a timing issue.
Common scenario
You render content, immediately call scrollTo(), and then images load or fonts swap and the layout shifts. The scroll target is now slightly off.
Practical fixes I use
1) Scroll on the next animation frame
This waits until the browser has had a chance to apply layout for the current frame.
function scrollToTopNextFrame() {
requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0, behavior: ‘auto‘ });
});
}
2) Scroll after a specific element is in the DOM
If you‘re scrolling to content that appears conditionally, don‘t guess. Wait for it.
async function waitForElement(selector, timeoutMs = 2000) {
const start = performance.now();
while (performance.now() – start < timeoutMs) {
const el = document.querySelector(selector);
if (el) return el;
await new Promise(r => setTimeout(r, 16));
}
throw new Error(‘Timed out waiting for ‘ + selector);
}
async function scrollToPricing() {
const pricing = await waitForElement(‘#pricing‘);
const behavior = window.matchMedia(‘(prefers-reduced-motion: reduce)‘).matches ? ‘auto‘ : ‘smooth‘;
const top = pricing.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top, behavior });
}
This polling approach is intentionally simple and framework-agnostic. In a component framework, I often tie this to a ref and a post-render hook instead.
3) If content shifts, reduce shifts
This is not a scrollTo() feature, but it changes everything: reserve space for images with width/height, use stable skeletons, and avoid late-inserting banners above the fold. When layout is stable, scroll targets stay stable.
Performance and scroll handlers: keep them light and passive
window.scrollTo() itself is typically cheap, but the code around scrolling can get expensive fast.
Here‘s what I do in production:
1) Mark scroll listeners as passive
If you‘re not calling event.preventDefault() inside a scroll/touch listener, use { passive: true }.
window.addEventListener(‘scroll‘, () => {
// Read-only work only.
}, { passive: true });
That helps the browser keep scrolling responsive.
2) Avoid doing DOM writes on every scroll tick
If you want to show/hide something, batch writes into a requestAnimationFrame loop.
let scheduled = false;
window.addEventListener(‘scroll‘, () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
const show = window.scrollY > 400;
document.documentElement.classList.toggle(‘show-back-to-top‘, show);
});
}, { passive: true });
This pattern usually keeps the per-frame cost in a reasonable range (often ~1-3ms for simple toggles on modern devices), instead of ballooning when you do layout reads and style writes repeatedly.
3) Don‘t spam scrollTo() in loops
If you call scrollTo() in response to scroll events, you can create feedback loops or jitter. When I need "snap-like" behavior, I reach for CSS scroll snapping first (scroll-snap-type) and only add JS when there‘s a real reason.
Common mistakes I see (and how I debug them quickly)
Mistake 1: Using window.scrollTo() for a scrollable container
If the thing that scrolls is a
Fix:
const panel = document.querySelector(‘[data-scroll-panel]‘);
panel.scrollTo({ top: 0, behavior: ‘smooth‘ });
Mistake 2: Expecting a callback or return value
scrollTo() returns nothing. If you need to react after scrolling ends, you can:
- Listen to
scrolland detect when you‘ve reached the target (simple but a bit manual) - Use a small timeout for UX-only effects (not precise)
- Prefer
scrollendwhere available and accept fallback behavior (support varies)
A pragmatic pattern:
function scrollToTopAndAnnounce() {
window.scrollTo({ top: 0, behavior: ‘smooth‘ });
// Announce after a short delay; keep it non-critical.
setTimeout(() => {
const live = document.getElementById(‘live-region‘);
if (live) live.textContent = ‘Back at the top of the page.‘;
}, 400);
}
Mistake 3: Scrolling to the wrong place because of a sticky header
If headings get covered, use scroll-margin-top or subtract the header height as shown earlier.
Mistake 4: Calling scroll code during server rendering
window doesn‘t exist on the server. If you‘re in a universal app, guard your call:
if (typeof window !== ‘undefined‘) {
window.scrollTo({ top: 0 });
}
Mistake 5: Fighting the browser‘s built-in scroll restoration
Browsers restore scroll position on back/forward navigation. Many routers also do their own thing.
My debugging approach:
- Confirm whether the browser is restoring scroll (try back/forward)
- Check whether the app sets
history.scrollRestoration = ‘manual‘ - Search codebase for
scrollTo(and router hooks
If you take manual control, do it intentionally and document it, because it affects perceived navigation quality.
When I reach for scrollTo() vs other scrolling tools
I treat window.scrollTo() as one tool among several.
I use window.scrollTo() when:
- I need an exact coordinate (like
top: 0for "back to top") - I‘m syncing scroll with UI state (open drawer, then scroll to a measured point)
- I need to correct position after layout changes (route transitions, deferred rendering)
- I want one abstraction that can switch between
‘smooth‘and‘auto‘easily
I avoid window.scrollTo() when:
- I‘m scrolling to an element and don‘t need math:
element.scrollIntoView()is clearer - I need scroll snapping: CSS scroll snap is simpler and more stable
- The scroll container is not the window: use
element.scrollTo() - I‘m trying to "animate" scrolling as a custom effect: native smooth scrolling is usually better for users and for performance
A simple analogy I use with teams: scrollTo() is like setting a GPS coordinate; scrollIntoView() is like saying "take me to that building." If you don‘t need coordinates, don‘t invent them.
You can ship polished scrolling with surprisingly little code, but only if you respect how the platform actually behaves.
What scrollTo() actually does (and what it does not)
I like to be explicit about the contract, because it prevents wishful thinking.
window.scrollTo() does:
- Request that the document scroll position becomes a given
{ top, left }. - Clamp values to the scrollable range (so you can‘t scroll beyond the top or beyond the bottom).
- Optionally animate the scroll when
behavior: ‘smooth‘is supported and allowed.
window.scrollTo() does not:
- Guarantee the final position if the layout changes mid-scroll.
- Provide a built-in completion callback.
- Scroll a nested container (that‘s
element.scrollTo()on the element). - Override user settings universally (for example, motion preferences should be respected).
A subtle point: for behavior: ‘smooth‘, the browser can choose details like duration and easing. That‘s a feature, not a bug. Native scrolling tends to feel consistent with the user‘s environment.
A mini reference: inputs, clamping, and coordinate gotchas
This is the little "API behavior" checklist I keep in my head.
top/left accept numbers, including non-integers
You can pass fractional values like top: 120.5. The scroll position itself is typically represented as a floating point value internally, even though UIs often display whole pixels. In practice, you won‘t notice fractional scroll offsets unless you‘re doing very specific measurements.
Negative values clamp to 0
If you do this:
window.scrollTo({ top: -200 });
The browser treats it like top: 0. I still clamp manually when I compute offsets because it makes intent obvious (and protects against NaN, which is a separate issue).
Values beyond the max clamp to the max
If the page can only scroll to top = 3800 and you request top = 999999, you land at the bottom.
scrollX/scrollY are the live truth
When debugging, I trust these in this order:
1) window.scrollY / window.scrollX
2) document.documentElement.scrollTop (if I need it)
3) document.body.scrollTop (legacy / quirks-y)
If you ever find yourself mixing these without a reason, it‘s a smell.
The documentElement vs body confusion (why the doctype matters)
Modern, standards-mode pages (with ) behave predictably. Without a doctype (quirks mode), browsers can use document.body as the scroller in ways that make older snippets show up in search results and confuse everyone.
My rule: if I‘m writing new code, I use window.scrollTo() and read window.scrollY. If I‘m maintaining old code, I check for a doctype and test on at least one WebKit-based browser and one Chromium-based browser.
Smooth scrolling: making it feel good without making it worse
Smooth scrolling is a "nice" feature until it becomes a "why is my UI fighting me" feature. I use a few simple principles.
Principle 1: Smooth is opt-in and context-sensitive
If a user clicks "Back to top" voluntarily, smooth scrolling can feel great. If the app forces a scroll while the user is trying to read, smooth scrolling can feel like the page is slipping away.
That‘s why I almost never use smooth scrolling for automatic navigations that happen without an explicit user action.
Principle 2: Respect prefers-reduced-motion
I already showed it in the back-to-top example, but I treat it as non-negotiable.
Here‘s the small helper I reuse:
function motionBehavior() {
const mq = window.matchMedia(‘(prefers-reduced-motion: reduce)‘);
return mq.matches ? ‘auto‘ : ‘smooth‘;
}
Then:
window.scrollTo({ top: 0, behavior: motionBehavior() });
Principle 3: Avoid stacking smooth scroll effects
One easy way to make scrolling feel "floaty" is to combine:
- CSS
scroll-behavior: smooth;onhtmland - JS
behavior: ‘smooth‘for programmatic scrolls and - in-page anchors that also smooth scroll.
You can end up with smooth scroll everywhere, including cases where it isn‘t desired.
My preference is:
- Keep CSS
scroll-behavioroff globally. - Turn on smooth scrolling explicitly per interaction via
behavior: ‘smooth‘.
If you do choose the CSS approach, make it intentional:
html {
scroll-behavior: smooth;
}
And still consider reduced motion:
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
Scrolling to sections: three production patterns
Scrolling to a section sounds trivial until you add sticky headers, dynamic content, and deep links.
Here are the three patterns I reach for.
Pattern A: Use scrollIntoView() + CSS scroll-margin-top
This is my default when I‘m targeting an element and I don‘t need absolute coordinates.
CSS:
:root { –header: 64px; }
[data-anchor] { scroll-margin-top: calc(var(–header) + 12px); }JS:
function scrollToAnchor(el) {
el.scrollIntoView({ behavior: motionBehavior(), block: ‘start‘ });
}
Why it works: the browser does the hard parts, and the CSS handles the header offset.
Pattern B: Use scrollTo() when you need a custom offset
Sometimes the offset isn‘t just header height. Maybe you want a section title to sit slightly below the header for visual breathing room.
function scrollToSection(sectionEl, { offset = 80 } = {}) {
const top = sectionEl.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top: Math.max(top – offset, 0), behavior: motionBehavior() });
}
This is also useful when your "header height" isn‘t constant (for example, a collapsible banner). You can compute the offset each time.
Pattern C: Scroll and then focus (for accessibility)
If your scroll is effectively navigation (like "Skip to content" or "Jump to results"), I like to set focus to a meaningful element.
function scrollToAndFocus(el) {
const top = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top, behavior: ‘auto‘ });
// Make sure it can receive focus without changing semantics.
if (!el.hasAttribute(‘tabindex‘)) el.setAttribute(‘tabindex‘, ‘-1‘);
el.focus({ preventScroll: true });
}
Notice preventScroll: true: it avoids a second scroll caused by focusing.
Hash links and deep linking: making URLs and scroll play nicely
Deep linking (using #section-id) is one of the simplest and best UX features on the web. But it intersects with scroll in a few ways.
Case 1: Let the browser handle it
If your page is mostly static and your layout is stable, you often don‘t need JS at all. Anchor navigation works, and with scroll-margin-top, the header won‘t cover the target.
Case 2: You render content asynchronously
If the element doesn‘t exist when the browser tries to jump to it, the scroll won‘t happen.
In that case, I do a small "on load" (or "after render") step:
async function scrollToHashIfPresent() {
const id = decodeURIComponent(location.hash.replace(/^#/, ‘‘));
if (!id) return;
try {
const el = await waitForElement(‘#‘ + CSS.escape(id), 3000);
el.scrollIntoView({ behavior: ‘auto‘, block: ‘start‘ });
} catch {
// If it doesn‘t show up, I don‘t keep trying forever.
}
}
I intentionally use behavior: ‘auto‘ here. Deep links often feel better as direct jumps, not animations.
Case 3: You need offsets with hash links
If you want to keep URLs shareable but still apply an offset, you can intercept clicks and/or handle hashchange.
function scrollToHashWithOffset(offsetPx) {
const id = decodeURIComponent(location.hash.replace(/^#/, ‘‘));
if (!id) return;
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top: Math.max(top – offsetPx, 0), behavior: ‘auto‘ });
}
Then wire it:
window.addEventListener(‘hashchange‘, () => scrollToHashWithOffset(72));
A note: if you do this, test back/forward navigation carefully. Hash changes are part of navigation.
Scroll restoration in navigation: picking one boss
If you‘re building a multi-page site, the browser‘s default behavior (restore scroll on back/forward) is usually great.
If you‘re building a single-page app, things get messy because multiple systems may try to control scroll:
- Browser history restoration
- Router-level scroll management
- Component effects calling
scrollTo() - Transitions that delay layout
My advice is simple: choose one boss.
When I let the browser handle restoration
- Content is mostly static per URL.
- Back/forward should feel like "returning" (including scroll position).
- I don‘t have heavy transitions that change layout timing.
When I take manual control
If I take manual control, it‘s because URLs represent distinct screens and I want consistent behavior.
In that case:
history.scrollRestoration = ‘manual‘;
Then I implement a single scroll policy in one place (router hook, navigation handler, or a central "page shell" component). A typical policy:
- New navigation to a new page: scroll to top.
- Same page with hash: scroll to anchor.
- Back/forward: restore stored scroll position.
The exact details depend on your app, but the big win is eliminating competing scroll code.
Detecting when scrolling is "done" (without lying to yourself)
Because scrollTo() has no callback, people reach for timeouts. That‘s fine for non-critical UI, but it‘s not reliable.
Here are the options, from most robust to most pragmatic.
Option 1: scrollend event (where available)
Some environments support a scrollend event on scroll containers. If you use it, you need a fallback.
function onScrollEndOnce(cb, timeoutMs = 1200) {
let done = false;
function finish() {
if (done) return;
done = true;
window.removeEventListener(‘scrollend‘, finish);
cb();
}
window.addEventListener(‘scrollend‘, finish, { once: true });
setTimeout(finish, timeoutMs);
}
Then:
window.scrollTo({ top: 0, behavior: motionBehavior() });
onScrollEndOnce(() => {
// Safe-ish place for a follow-up effect.
});
I still include a timeout because support and behavior can vary.
Option 2: Observe the target position
If you have a numeric target, you can watch scrollY until it‘s close.
function waitForScrollY(targetY, { epsilon = 2, timeoutMs = 1500 } = {}) {
return new Promise((resolve, reject) => {
const start = performance.now();
function tick() {
const y = window.scrollY;
if (Math.abs(y – targetY) <= epsilon) return resolve();
if (performance.now() – start > timeoutMs) return reject(new Error(‘scroll timeout‘));
requestAnimationFrame(tick);
}
tick();
});
}
This works well for UI sequencing, but it‘s still not perfect if layout changes mid-flight.
Option 3: Use a timeout and treat it as a hint
If I‘m only doing something cosmetic (like fading a button), a timeout is fine.
The key is: don‘t use timeouts for critical correctness.
Dynamic pages: infinite lists, lazy images, fonts, and "why did it scroll wrong"
I separate scroll problems into two types:
1) My scroll request didn‘t run at the right time.
2) My scroll request ran, but the layout moved afterwards.
The earlier timing section covers (1). This section is about (2).
Images and media
If images don‘t reserve space, content below them will jump when they load. Any scroll target below them becomes unstable.
My fix isn‘t "more scroll math". It‘s layout stability:
- Use
widthandheightattributes on images. - Use aspect-ratio boxes.
- Avoid injecting banners above the content after initial render.
Fonts
Web fonts can cause layout shifts when they swap in (especially with large headings). I test scroll-to-section flows both on a warm cache and a cold cache.
If you see shifts, consider:
- Using a fallback font with similar metrics.
- Using a font loading strategy that minimizes reflow.
Infinite lists / virtualized lists
This is where window.scrollTo() is still useful, but you need to accept that the content might not exist yet.
A common pattern:
- If the item isn‘t rendered, scroll to an approximate position.
- Render the item.
- Then correct scroll once its actual position is measurable.
In practice, I do this in two passes:
async function scrollToItem(getItemEl, approximateY) {
window.scrollTo({ top: approximateY, behavior: ‘auto‘ });
await new Promise(r => requestAnimationFrame(r));
const el = getItemEl();
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY;
window.scrollTo({ top, behavior: ‘auto‘ });
}
This is one of the few places where "scroll twice" is the right move.
A more complete utility: scroll to element with sticky header detection
Teams often hardcode header height and then forget it changes on mobile. Here‘s a utility I ship when I want a single reliable function.
function getStickyHeaderOffset() {
const header = document.querySelector(‘[data-sticky-header]‘);
if (!header) return 0;
const rect = header.getBoundingClientRect();
// If it‘s not actually sticking in the current layout, don‘t subtract it.
// (A header can be present but not sticky due to breakpoint styles.)
const isOnTop = Math.abs(rect.top) < 1;
if (!isOnTop) return 0;
return Math.ceil(rect.height);
}
function scrollToElement(el, { extraOffset = 12, behavior } = {}) {
const offset = getStickyHeaderOffset() + extraOffset;
const top = el.getBoundingClientRect().top + window.scrollY;
const finalBehavior = behavior || motionBehavior();
window.scrollTo({
top: Math.max(top – offset, 0),
left: 0,
behavior: finalBehavior,
});
}
This does three things I care about:
- It measures the current header height rather than guessing.
- It only subtracts it when the header is actually sitting at the top.
- It adds a small extra offset for breathing room.
Horizontal scrolling: yes, left matters sometimes
Most pages are only vertically scrollable, so top dominates. But I still see cases where left is important:
- Large tables where you want to reveal a specific column
- Code viewers
- Canvas/diagram tools
Example: scroll to a table cell inside a horizontally scrollable layout (window-level horizontal scroll is rarer, but it exists).
window.scrollTo({ left: 320, top: window.scrollY, behavior: ‘auto‘ });
If the thing you‘re scrolling is a container, not the window:
const scroller = document.querySelector(‘.tableWrap‘);
scroller.scrollTo({ left: 320, behavior: ‘smooth‘ });
Accessibility: scrolling is not the same as navigation
This is where good scrolling turns into great UX.
Reduced motion is part of accessibility
I treat prefers-reduced-motion as a user preference that must be respected, not as a nice-to-have.
Focus management matters
If your scroll is triggered by a control that changes context (for example, "Skip to results"), consider moving focus.
A good rule of thumb:
- If it‘s purely a convenience ("Back to top"), you can keep focus on the button.
- If it‘s a navigation action ("Jump to section"), move focus to the destination.
When moving focus, I use preventScroll: true to avoid double-scroll:
destination.focus({ preventScroll: true });
Announcements should be rare and meaningful
I only use live-region announcements for cases where the scroll meaningfully changes context and the destination isn‘t obvious.
If you announce everything, it becomes noise.
Mobile and cross-browser quirks I actually test for
I don‘t try to memorize every edge case. I do keep a short list of behaviors I verify when scroll feels off.
1) Visual viewport vs layout viewport (mobile browser UI)
On mobile, the visible area can change as the browser chrome expands/collapses. That can change how much content is visible without changing the document coordinates.
The important part: your top coordinate is still in document space, but what the user sees can shift slightly. If you‘re aligning content perfectly under a sticky header on mobile, test it on real devices.
2) Overscroll / rubber-banding
Some browsers allow "rubber band" overscroll at the top or bottom. Your code may see negative-ish effects visually even if scrollY stays clamped.
I avoid building logic that depends on overscroll visuals.
3) Smooth scrolling interruptions
Users can interrupt smooth scrolling by manual input (wheel/touch). That‘s good. If your UI assumes smooth scrolling will always complete, it will feel fragile.
That‘s another reason I avoid critical logic after a smooth scroll.
Testing and debugging: a quick checklist
When someone says "scrollTo doesn‘t work," I run this list.
1) Is the window the actual scroller?
– If the page uses a full-height scrolling container, window.scrollTo() may do nothing.
2) Are you calling it at the right time?
– Try wrapping the call in requestAnimationFrame.
3) Is your requested position reachable?
– Log the target top and compare it to the max scroll.
4) Is something else fighting you?
– Search for other scroll code, router hooks, and scroll restoration settings.
5) Does layout shift after the scroll?
– Images, fonts, banners, and late content can move the target.
6) Are you mixing scroll containers?
– Some components scroll internally while the window stays fixed.
If I‘m debugging quickly, I add temporary logs:
console.log({ before: window.scrollY, targetTop });
window.scrollTo({ top: targetTop, behavior: ‘auto‘ });
requestAnimationFrame(() => console.log({ after: window.scrollY }));
That makes it obvious whether the call ran, whether it clamped, and whether something moved immediately afterward.
Practical scenarios I ship with window.scrollTo()
To make this feel concrete, here are real situations where scrollTo() is my default.
Scenario 1: Reset scroll on "new page" navigation
When a navigation represents a new page-like screen, I scroll to top after the new content is ready.
function onNavigateToNewScreen() {
requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0, behavior: ‘auto‘ });
});
}
I keep it ‘auto‘ because it feels snappy and predictable. Smooth scrolling here can feel like the app is slow.
Scenario 2: A "scroll to results" button that stays honest
I want a button that scrolls the user to a results area that may render later.
async function scrollToResults() {
const results = await waitForElement(‘[data-results]‘, 2000);
scrollToElement(results, { extraOffset: 16, behavior: motionBehavior() });
// If the results represent a context shift, focus the heading.
const heading = results.querySelector(‘h2, [role="heading"]‘);
if (heading) {
if (!heading.hasAttribute(‘tabindex‘)) heading.setAttribute(‘tabindex‘, ‘-1‘);
heading.focus({ preventScroll: true });
}
}
Scenario 3: Restore scroll after expanding a details panel
If a user expands content above the fold, the page can push their reading position down.
One pattern is to anchor the current position before the change, then adjust after.
function getAnchorTop(el) {
return el.getBoundingClientRect().top + window.scrollY;
}
async function expandWithoutLosingPlace(anchorEl, expandFn) {
const beforeTop = getAnchorTop(anchorEl);
expandFn();
await new Promise(r => requestAnimationFrame(r));
const afterTop = getAnchorTop(anchorEl);
const delta = afterTop – beforeTop;
// Keep the anchor in the same visual spot.
window.scrollTo({ top: window.scrollY + delta, behavior: ‘auto‘ });
}
This is one of those "feels premium" details that users can‘t name, but they notice when it‘s missing.
Expansion Strategy
Add new sections or deepen existing ones with:
- Deeper code examples: More complete, real-world implementations
- Edge cases: What breaks and how to handle it
- Practical scenarios: When to use vs when NOT to use
- Performance considerations: Before/after comparisons (use ranges, not exact numbers)
- Common pitfalls: Mistakes developers make and how to avoid them
- Alternative approaches: Different ways to solve the same problem
If Relevant to Topic
- Modern tooling and AI-assisted workflows (for infrastructure/framework topics)
- Comparison tables for Traditional vs Modern approaches
- Production considerations: deployment, monitoring, scaling
The main takeaway I want you to leave with is this: window.scrollTo() is simple, but scrolling behavior is not. If you treat scroll like a first-class part of your UX (timing, offsets, motion preferences, and layout stability), the method becomes a dependable building block instead of a source of "why did it do that" bugs.


