The React Systems Newsletter
A long form is one of the easiest places to notice bad scrolling behavior.
The user clicks Submit. Validation fails somewhere much farther down the page. React renders the error correctly, but the user cannot see it. From their point of view, nothing happened.
Thanks for reading! Subscribe for free to receive new posts and support my work.
Subscribe
The fix can be only a few lines:
import { useRef } from "react";export default function Article() {
const detailsRef = useRef<HTMLDivElement>(null);
function goToDetails() {
detailsRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
return (
<>
<button type="button" onClick={goToDetails}>
Go to details
</button>
<div style={{ minHeight: "120vh" }} />
<section ref={detailsRef}>
<h2>Details</h2>
<p>This is the section we wanted to reach.</p>
</section>
</>
);
}
For basic navigation, this is usually all you need.
`useRef` gives you access to the DOM element. `scrollIntoView()` asks the browser to bring that element into view. The optional chaining is there because `detailsRef.current` is still `null` before React mounts the section.
There is no library involved, no manual coordinate calculation, and no extra state.
The interesting part begins when the page gets more complicated.
The native `scrollIntoView()` API has a few useful options, but you do not need to memorize all of them before using it.
For most React components, these three patterns cover nearly everything.
Scroll the element to the top:
element.scrollIntoView();Move it smoothly toward the center:
element.scrollIntoView({
behavior: "smooth",
block: "center",
});Move only when necessary:
element.scrollIntoView({
block: "nearest",
});The last one is especially useful.
Imagine a command menu with twenty results. The user moves through them with the keyboard. If every Arrow Down press centers the active item, the list keeps jumping around even when the next item is already visible.
`block: "nearest"` behaves differently. The browser moves only enough to reveal the target. If the item is already visible, it may not scroll at all.
That small difference makes keyboard-controlled lists feel much calmer.
There is also an older boolean version:
element.scrollIntoView(true);
element.scrollIntoView(false);It still works, but I would avoid it in new code. The object form makes the intention much easier to understand later.
element.scrollIntoView({
block: "end",
});One more thing catches people by surprise. `scrollIntoView()` is not limited to `window`.
If the target sits inside a scrollable sidebar or panel, the browser can scroll that container too. In nested layouts, more than one scrollable ancestor may move so the element becomes visible.
Most of the time, this saves you from writing extra code.
A sticky navigation bar creates one of the most common scrolling bugs.
The element reaches the top of the page exactly as requested, then the header covers it.
The first solution people often write looks like this:
const HEADER_HEIGHT = 72;const top =
element.getBoundingClientRect().top +
window.scrollY -
HEADER_HEIGHT;
window.scrollTo({
top,
behavior: "smooth",
});
It works until the header changes.
Then mobile uses another height. A promo banner appears above it. Someone changes the spacing. The target moves into a nested scrollable container and the calculation no longer describes the real layout.
Now the scrolling code owns a magic number that belongs to the CSS.
There is already a property for this:
.article-section {
scroll-margin-top: 5rem;
}If the header height is stored in a variable, even better:
:root {
--header-height: 5rem;
}.article-section {
scroll-margin-top: var(--header-height);
}
Your JavaScript can stay simple:
sectionRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});`scroll-margin-top` does not move the element in the normal layout. It only changes how much space the browser leaves when positioning that element during scrolling.
For a fixed or sticky header, this should be the first solution you try.
This is where many otherwise correct examples break.
Suppose you add a new row and want to scroll to it immediately:
function addRow() {
setRows((current) => [...current, createRow()]); lastRowRef.current?.scrollIntoView({
behavior: "smooth",
});
}
The code looks reasonable, but the new row may not exist yet.
Calling `setRows()` schedules an update. React still has to render the new list and commit the result to the DOM.
The next line runs before that process is guaranteed to be finished.
At that moment, `lastRowRef.current` can still point to the previous row.
An effect is the simplest solution when scrolling should happen because state changed.
useEffect(() => {
if (rows.length === 0) return; lastRowRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, [rows.length]);
Now the order is correct.
State changes first. React renders the new row. The DOM is updated. Then the effect runs and scrolls to the element that actually exists.
For smooth scrolling, `useEffect` is normally enough.
There are a few cases where you want the position corrected before the browser paints the frame. `useLayoutEffect` can help there.
useLayoutEffect(() => {
activeItemRef.current?.scrollIntoView({
block: "nearest",
});
}, [activeId]);Do not use `useLayoutEffect` everywhere just because it sounds more precise. It blocks painting while it runs, so it is better saved for UI work where that timing really matters.
Sometimes you do not care which state value changed.
You only care about this:
“Run some code when this DOM node appears.”
A callback ref expresses that directly.
import { useCallback } from "react";const scrollWhenMounted = useCallback(
(node: HTMLLIElement | null) => {
if (!node) return;
node.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
},
[]
);
You can attach it to the last row:
<ul>
{rows.map((row, index) => {
const isLast = index === rows.length - 1; return (
<li
key={row.id}
ref={isLast ? scrollWhenMounted : undefined}
>
{row.label}
</li>
);
})}
</ul>
React calls the ref when it attaches the node.
No dependency array is needed, and there is no separate effect whose only purpose is waiting for that element to show up.
For components created conditionally, this often reads better.
React also gives you a way to force an update to finish synchronously.
import { flushSync } from "react-dom";function openDetails() {
flushSync(() => {
setExpanded(true);
});
detailsRef.current?.scrollIntoView({
behavior: "smooth",
});
}
After `flushSync()` returns, React has committed the update.
That means the element rendered by `setExpanded(true)` can already be available to the ref.
Useful? Yes.
Something to reach for first? Usually not.
`flushSync` forces React to perform work immediately. That means you are stepping outside its normal batching and scheduling behavior.
If an effect or callback ref gives you the same result, the simpler React flow is usually easier to maintain.
The native API is good because it is small.
It is also small because it does not expose much control.
That becomes noticeable once scrolling turns into part of the interface animation rather than simple navigation.
This works:
element.scrollIntoView({
behavior: "smooth",
});This does not:
element.scrollIntoView({
behavior: "smooth",
duration: 500,
});There is no `duration` option.
The browser decides how the smooth movement behaves.
That is fine for jumping between sections. It is less useful when your scroll must line up with another animation that takes a very specific amount of time.
You cannot do this:
await element.scrollIntoView({
behavior: "smooth",
});input.focus();
`scrollIntoView()` does not return a Promise that resolves when the movement ends.
There is a `scrollend` event in modern browsers, but it is separate from the original function call and may not fit every interaction cleanly.
Using a timeout is tempting:
element.scrollIntoView({
behavior: "smooth",
});setTimeout(() => {
input.focus();
}, 500);
The problem is that `500` is only a guess.
The browser did not promise you a 500ms animation.
A programmatic smooth scroll can start, then the user may decide they want to move somewhere else.
The native function does not return an animation controller:
const scroll = element.scrollIntoView({
behavior: "smooth",
});// There is no API like this.
scroll.cancel();
For small page jumps, you probably do not care.
For long transitions, cancellation starts to matter.
If you explicitly request animated scrolling, you should also consider people who have asked the operating system to reduce motion.
A helper can keep that logic in one place:
function getScrollBehavior(): ScrollBehavior {
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches; return reduceMotion ? "auto" : "smooth";
}
Then use it normally:
element.scrollIntoView({
behavior: getScrollBehavior(),
block: "start",
});This is easy when you have one scroll interaction.
It becomes easier to forget once the same pattern appears across several components.
Once you need custom timing, cancellation, callbacks, offsets, or reusable container behavior, a hook can be justified.
One option is `useScrollIntoView` from `@reactuses/core`.
Install it with:
npm install @reactuses/coreThen:
import { useRef } from "react";
import { useScrollIntoView } from "@reactuses/core";export default function Article() {
const targetRef = useRef<HTMLParagraphElement>(null);
const { scrollIntoView, cancel } = useScrollIntoView(
targetRef,
{
duration: 600,
offset: 80,
onScrollFinish: () => {
targetRef.current?.focus();
},
}
);
return (
<>
<button
type="button"
onClick={() =>
scrollIntoView({
alignment: "center",
})
}
>
Go to details
</button>
<div style={{ minHeight: "150vh" }} />
<p ref={targetRef} tabIndex={-1}>
Details
</p>
<button type="button" onClick={cancel}>
Stop
</button>
</>
);
}
This is a different level of abstraction.
The native browser version gives you simple positioning and browser-controlled smooth motion.
A custom hook can give you things such as animation duration, an easing function, an offset, cancellation, horizontal scrolling, and a callback after the movement finishes.
Those features are useful when the scroll itself is part of the interaction.
They are unnecessary when all you need is “go to this heading.”
Imagine a page starts scrolling for almost a second.
Halfway through, the user touches the trackpad because they want to stop at another section.
If your animation keeps pulling the page toward its original target, the UI suddenly feels like it is fighting them.
Cancelable scrolling solves that problem.
You may also want to stop scrolling when a component disappears.
const { scrollIntoView, cancel } =
useScrollIntoView(targetRef);useEffect(() => {
return () => {
cancel();
};
}, [cancel]);
That can matter when the target belongs to a modal, drawer, route, or temporary panel.
If the surrounding UI is gone, continuing its animation makes little sense.
Not every scroll belongs to the page.
Autocomplete menus, tables, sidebars, message lists, and dropdowns often scroll inside their own element.
In that case, keep references to both the container and target.
const listRef = useRef<HTMLDivElement>(null);
const itemRef = useRef<HTMLLIElement>(null);const { scrollIntoView } = useScrollIntoView(
itemRef,
{
isList: true,
},
listRef
);
Now the scrolling behavior is tied to the list instead of the document.
<button
type="button"
onClick={() =>
scrollIntoView({
alignment: "start",
})
}
>
Find item
</button>Passing the container explicitly can also make the component easier to understand.
Someone reading the code later does not have to inspect the CSS just to work out which element is supposed to move.
A carousel is the obvious example.
const trackRef = useRef<HTMLDivElement>(null);
const slideRef = useRef<HTMLDivElement>(null);const { scrollIntoView } = useScrollIntoView(
slideRef,
{
axis: "x",
duration: 400,
},
trackRef
);
function centerCurrentSlide() {
scrollIntoView({
alignment: "center",
});
}
Before reaching for JavaScript here, also consider CSS scroll snapping.
Many carousels can be built with native scrolling and `scroll-snap-type`, which keeps the browser responsible for most of the interaction.
Custom animation is more useful when you need exact control over how the transition happens.
Now back to the form from the beginning.
A clean version should not try to scroll before React has rendered the errors.
Instead, store which field failed first, then react to that state.
import {
FormEvent,
useEffect,
useRef,
useState,
} from "react";type FieldName = "email" | "address" | "city";
type Errors = Partial<Record<FieldName, string>>;
export default function CheckoutForm() {
const [errors, setErrors] = useState<Errors>({});
const [firstInvalid, setFirstInvalid] =
useState<FieldName | null>(null);
const fieldRefs = useRef<
Partial<Record<FieldName, HTMLInputElement | null>>
>({});
useEffect(() => {
if (!firstInvalid) return;
const input = fieldRefs.current[firstInvalid];
input?.scrollIntoView({
behavior: "smooth",
block: "center",
});
input?.focus({
preventScroll: true,
});
}, [firstInvalid]);
function handleSubmit(
event: FormEvent<HTMLFormElement>
) {
event.preventDefault();
const nextErrors = validateForm();
const firstField =
(Object.keys(nextErrors)[0] as
| FieldName
| undefined) ?? null;
setErrors(nextErrors);
setFirstInvalid(firstField);
if (!firstField) {
submitForm();
}
}
return (
<form onSubmit={handleSubmit}>
<label>
Email
<input
name="email"
ref={(node) => {
fieldRefs.current.email = node;
}}
/>
{errors.email && (
<span role="alert">
{errors.email}
</span>
)}
</label>
<label>
Address
<input
name="address"
ref={(node) => {
fieldRefs.current.address = node;
}}
/>
{errors.address && (
<span role="alert">
{errors.address}
</span>
)}
</label>
<label>
City
<input
name="city"
ref={(node) => {
fieldRefs.current.city = node;
}}
/>
{errors.city && (
<span role="alert">
{errors.city}
</span>
)}
</label>
<button type="submit">
Place order
</button>
</form>
);
}
This version separates responsibilities nicely.
The submit handler validates and updates state.
The effect handles the DOM work after React has committed that update.
Focus is applied with `preventScroll: true` so the browser does not perform another unexpected movement after your own scrolling logic runs.
You can also add spacing for the header in CSS:
input {
scroll-margin-top: 6rem;
}No header calculation is needed in the component.
There is a big gap between repeating `scrollIntoView()` everywhere and installing a full scrolling solution.
A small local hook can cover that middle ground.
import {
RefObject,
useCallback,
} from "react";type ScrollOptions = {
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
};
export function useScrollToElement<
T extends HTMLElement
>(
ref: RefObject<T | null>
) {
return useCallback(
(options: ScrollOptions = {}) => {
ref.current?.scrollIntoView({
behavior:
options.behavior ?? "smooth",
block:
options.block ?? "nearest",
inline:
options.inline ?? "nearest",
});
},
[ref]
);
}
Then a component only needs this:
const sectionRef =
useRef<HTMLElement>(null);const scrollToSection =
useScrollToElement(sectionRef);
And later:
<button
type="button"
onClick={() =>
scrollToSection({
block: "start",
})
}
>
Read more
</button><section ref={sectionRef}>
...
</section>
This keeps your default behavior consistent without introducing an animation system.
For many applications, that is enough.
One subtle source of bad scrolling is having two different systems trying to animate the same container.
For example:
.scroll-container {
scroll-behavior: smooth;
}At the same time, JavaScript may be updating `scrollTop` every frame as part of its own custom animation.
Now the browser is trying to smooth values that your animation is already changing continuously.
The result can feel delayed or strangely elastic.
Pick one source of animation for a container.
If JavaScript controls the movement frame by frame, do not also ask CSS to smooth those changes.
Smooth scrolling is not automatically the nicer choice.
Keyboard navigation is a good example.
When a user moves rapidly through list items, a long animation after every key press makes the interface feel slow. `block: "nearest"` with instant movement is often much more natural.
activeItem.scrollIntoView({
block: "nearest",
behavior: "auto",
});The same is true when the user has requested reduced motion.
Animation should help orientation. When it starts delaying interaction, it has stopped helping.
A table of contents does not need a scrolling library.
sectionRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});Add CSS for the sticky header:
section {
scroll-margin-top: var(--header-height);
}A keyboard-controlled menu can stay native too:
activeItem.scrollIntoView({
block: "nearest",
});If you want both horizontal and vertical alignment, the browser supports that:
element.scrollIntoView({
block: "center",
inline: "center",
});If your target is a number rather than an element, use `scrollTo()`:
window.scrollTo({
top: 800,
behavior: "smooth",
});If you only want to know whether something is visible, use `IntersectionObserver`.
Not every scroll-related problem should end with another abstraction.
The simple React pattern is still the one worth remembering:
const targetRef =
useRef<HTMLDivElement>(null);targetRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
That handles a large part of what people actually need.
When a fixed header gets in the way, use `scroll-margin-top` before reaching for manual coordinates.
When React has just created the target, scroll after the DOM update rather than immediately after `setState`. An effect works well for state-driven changes. A callback ref is often cleaner when mounting the element itself is the event you care about.
`flushSync` can force the DOM to update immediately, but it should remain an exception rather than the normal pattern.
A custom scrolling hook starts earning its place once you need more control than the browser gives you. Duration, easing, cancellation, completion callbacks, offsets, or a specific scroll container are reasonable reasons to move beyond native `scrollIntoView()`.
Until then, keep it simple.
React gives you the ref. The browser already knows how to scroll.
Thanks for reading! Subscribe for free to receive new posts and support my work.
Subscribe



