Dev.to · 12 min read

React scrollIntoView with useRef: Scroll to an Element (2026)

React scrollIntoView with useRef: Scroll to an Element (2026)

You have a long form. The user hits Submit, validation fails on a field three screens down, and the error message renders somewhere they can't see. The fix is one browser API call — but where you put it, and what you pass it, is where an afternoon goes. The short answer, which is what most people are here for: import { useRef } from "react"; function Article() { const sectionRef = useRef(null); return ( sectionRef.current?.scrollIntoView({ behavior: "smooth" })}> Jump to details {/* … a lot of content … */} Details ); } That's the whole pattern: a ref on the element, .scrollIntoView() in the handler, ?. because sectionRef.current is null until React commits. It's built into every browser, it costs nothing, and for a static anchor like this it's the right answer — don't reach for a library. This post covers the rest of it: what the arguments actually do, the sticky-header offset problem (and why the CSS answer beats the JavaScript one), how to scroll to something that was just rendered, and the four things the native call genuinely can't do — at which point useScrollIntoView from @reactuses/core earns its place. The Arguments You Actually Have Element.scrollIntoView() takes one optional options object with three keys: Option Values Default What it does block start · center · end · nearest start Alignment along the block axis — vertical in a normal writing mode inline start · center · end · nearest nearest Alignment along the inline axis — horizontal behavior auto · instant · smooth auto auto follows the CSS scroll-behavior of the scrolling box So the three calls worth memorizing: el.scrollIntoView(); // snap it to the top el.scrollIntoView({ behavior: "smooth", block: "center" }); // glide it to the middle el.scrollIntoView({ block: "nearest" }); // move only if it's off-screen block: "nearest" is the underrated one. It scrolls the minimum distance needed to bring the element into view and does nothing at all if the element is already visible — exactly what you want for keyboard navigation in a listbox, where re-centering on every arrow key makes the list feel like it's fighting you. There's also a legacy boolean form: scrollIntoView(true) means block: "start", scrollIntoView(false) means block: "end". It still works everywhere; the object form says what it means. One thing that surprises people: scrollIntoView scrolls every scrollable ancestor, not just the nearest one. If your element sits in a scrollable panel inside a scrollable page, both move so the element ends up visible. That's almost always what you wanted. Sticky Headers: Use CSS, Not a Magic Number The single most common follow-up: you scroll to a heading, and your 64px sticky header sits right on top of it. The instinct is to compute it by hand: // don't const top = el.getBoundingClientRect().top + window.scrollY - 64; window.scrollTo({ top, behavior: "smooth" }); Now you own that 64. It's wrong on mobile where the header is shorter, wrong when a promo banner appears above it, wrong when the element is inside a scroll container rather than the page, and you've given up scrollIntoView's ancestor handling to boot. The platform has a property for exactly this: .section { scroll-margin-top: 5rem; /* or var(--header-height) */ } scroll-margin-top tells the browser to treat the element as if it had that much extra margin for scrolling purposes only. Plain el.scrollIntoView({ behavior: "smooth" }) then stops 5rem short, layout is untouched, and the value lives next to the header height it depends on. It also fixes :target anchors and browser find-in-page for free, which the JavaScript version never will. Reach for scroll-margin-top first. Every time. Scrolling to Something That Just Rendered The other half of the problem is timing. You add an item to a list and want to scroll to it; you open an accordion and want to reveal it; you set an error and want to jump to it. The naive version doesn't work: // broken: the DOM doesn't have the new row yet function addRow() { setRows(r => [...r, newRow]); lastRowRef.current?.scrollIntoView(); // still the *old* last row, or null } setRows schedules a render. React commits it later — and under React 18+ concurrent rendering, "later" is genuinely not this tick. At the moment that line runs, the DOM is still the old DOM. The default fix is an effect. Scroll after the commit that added the row: useEffect(() => { lastRowRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [rows.length]); Use useLayoutEffect instead if you want an instant scroll to land before the browser paints — otherwise the user sees one frame at the old position, which reads as a flicker. For a smooth scroll it doesn't matter; the animation starts either way. Callback refs are cleaner for "the element I just created". No effect, no dependency array, no ref to keep in sync — the callback fires the moment React attaches the node: const scrollOnMount = useCallback((node: HTMLElement | null) => { node?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, []); // … {rows.map((row, i) => ( ))} flushSync is the escape hatch, not the default. If you truly must scroll in the same event handler that changed the state, you can force the commit: import { flushSync } from "react-dom"; flushSync(() => setExpanded(true)); detailsRef.current?.scrollIntoView({ behavior: "smooth" }); It works, and it costs you the batching and concurrency React was doing on your behalf. Fine as a one-off in a handler; a smell if it shows up three times in a file. Where the Native Call Runs Out For anchors, "scroll to the error", and keyboard list navigation, everything above is enough and you should stop reading. Four things it genuinely cannot do: 1. You can't control the duration or the curve. behavior: "smooth" is whatever the browser decides — different speed in Chrome and Firefox, and no knob at all. If the scroll is part of a choreographed transition that has to line up with a 400ms fade, you can't. 2. There's no reliable "it finished" callback. The scrollend event was designed for this and landed in Chrome/Edge 114 and Firefox 109, with Safari following later — check support before you depend on it, and note it doesn't tell you which programmatic scroll ended. The workarounds people ship instead (a setTimeout guess, polling scrollY until it stops changing) are exactly as fragile as they sound. 3. You can't cancel it. Start a long smooth scroll, and if the user grabs the wheel halfway down, the browser keeps dragging them to the destination. On a long page this is the single most annoying scroll bug there is, and there is no API to stop it. 4. It ignores prefers-reduced-motion. Browsers do not universally downgrade behavior: "smooth" for users who asked for reduced motion — that's on you: const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; el.scrollIntoView({ behavior: reduce ? "auto" : "smooth" }); Easy to write once, easy to forget in the other eleven places you scroll. useScrollIntoView useScrollIntoView runs the animation itself on requestAnimationFrame, which is what buys back all four: npm install @reactuses/core import { useRef } from "react"; import { useScrollIntoView } from "@reactuses/core"; function Article() { const targetRef = useRef(null); const { scrollIntoView, cancel } = useScrollIntoView(targetRef, { duration: 600, offset: 80, onScrollFinish: () => targetRef.current?.focus(), }); return ( scrollIntoView({ alignment: "center" })}>Jump to details Details ); } useScrollIntoView(target, options?, scrollContainer?) returns { scrollIntoView, cancel }. It's SSR-safe — nothing touches the DOM until you call it — and the target can be a ref, an element, or a getter function, so it works with whatever you already have. The options, all optional: Option Default Notes duration 1250 Milliseconds. 0 jumps instantly. easing easeInOutQuad Any (t: number) => number over 0…1. axis "y" "x" for horizontal scrollers. One axis per hook. offset 0 Extra distance from the edge — the sticky-header allowance. cancelable true Wheel or touch input aborts the animation. isList false Skip the scroll when the target is already in view. onScrollFinish — Fires when the animation settles. And the alignment goes on the call, not the config, because it's usually per-invocation: scrollIntoView({ alignment: "start" | "center" | "end" }). Cancelable is the one you'll actually feel With cancelable: true (the default) the hook watches for wheel and touchmove and stops the animation where it is. The user reaches for the scrollbar mid-flight and the page just… lets them. Compare that with behavior: "smooth", which will happily fight a user for a full second. You can also stop it yourself — closing the modal that triggered the scroll, say: const { scrollIntoView, cancel } = useScrollIntoView(targetRef); useEffect(() => cancel, [cancel]); // it also cancels on unmount Reduced motion is handled The hook reads prefers-reduced-motion internally via useReducedMotion. When the user has asked for less motion, the easing collapses to its final value and the scroll becomes an instant jump — same destination, same onScrollFinish, no animation. You don't write the branch. Scrolling inside a container, and sideways Pass a scroll container as the third argument when you want to move a specific element's scroll position rather than the page: const listRef = useRef(null); const itemRef = useRef(null); const { scrollIntoView } = useScrollIntoView(itemRef, { isList: true }, listRef); Without the third argument the hook walks up from the target and picks the first ancestor whose computed overflow-x/overflow-y is auto or scroll, falling back to the page. That auto-detection is convenient and correct most of the time; pass the container explicitly when you know it. For a carousel, flip the axis: const { scrollIntoView } = useScrollIntoView(slideRef, { axis: "x", duration: 400 }, trackRef); scrollIntoView({ alignment: "center" }); Scroll to the first invalid field The pattern that started this post, with the pieces in the right places: function CheckoutForm() { const [errors, setErrors] = useState({}); const firstErrorRef = useRef(null); const { scrollIntoView } = useScrollIntoView(firstErrorRef, { offset: 96, // clear the sticky header duration: 500, onScrollFinish: () => firstErrorRef.current?.querySelector("input")?.focus(), }); function onSubmit(e: React.FormEvent) { e.preventDefault(); const next = validate(values); setErrors(next); if (Object.keys(next).length > 0) scrollIntoView({ alignment: "start" }); } const firstErrorField = Object.keys(errors)[0]; return ( {FIELDS.map(f => ( ))} ); } Because the hook resolves the target when you call scrollIntoView — not when it renders — calling it in the same handler as setErrors works even though firstErrorRef is attached by the render that setErrors triggers. No flushSync, no effect. Moving focus in onScrollFinish rather than immediately means screen-reader users and sighted users arrive at the same time. Gotchas Worth Knowing offset doesn't apply to alignment: "center". It's an allowance measured from the nearest edge, so it only affects "start" and "end". Centering something under a sticky header means either using "start" with an offset, or accepting the center. This one silently does nothing if you assume otherwise. Don't combine it with scroll-behavior: smooth. The hook animates by assigning scrollTop/scrollLeft every frame. If CSS also says that box scrolls smoothly, the browser tries to animate each of those ~60 assignments and the result is a stuttering mess. Pick one: CSS smooth scrolling or this hook, per container. One axis per hook. axis is "x" or "y", not both. A grid that needs diagonal movement needs two hooks, or the native call. The auto-detected scroll parent is cached per element. The first lookup for a given node is remembered. If your layout toggles overflow on an ancestor at runtime — a panel that becomes scrollable only when expanded — pass the container as the third argument instead of relying on detection. cancelable covers wheel and touch, not keys. Page Down and the scrollbar don't abort the animation. It's the common case, not every case; call cancel() yourself from a keydown handler if that matters to you. isList is directional. With isList: true the hook only moves when the target is outside the container on the side implied by alignment — a target already visible produces no scroll at all. That's the point (it stops a keyboard-navigated list from jittering on every keystroke), but it means isList: true with the wrong alignment can look like the hook is ignoring you. duration: 0 is an instant jump, not a no-op. Useful for honouring your own "no animations" setting without branching on which function to call. When to Skip the Hook Native scrollIntoView is the right call more often than not: A static anchor or a table-of-contents link → el.scrollIntoView({ behavior: "smooth" }) plus scroll-margin-top. No dependency, no animation loop. Keyboard navigation in a listbox → block: "nearest" does the minimum-movement behaviour natively, and instant is the correct feel there anyway. You need to align on both axes at once → the native call takes block and inline. You're scrolling to a position, not an element → window.scrollTo / el.scrollTo, or useScroll to read and react to scroll position. You want to know what's on screen rather than move to it → useIntersectionObserver, which is also how you highlight the current section in a TOC. You want to stop the page scrolling entirely (modal open) → useScrollLock. Takeaways The baseline is three lines: useRef on the element, ref.current?.scrollIntoView({ behavior: "smooth" }) in the handler, ?. because the ref is null before commit. Learn block: "nearest" — it's the one you'll use most. Solve sticky-header overlap with scroll-margin-top in CSS, not by subtracting a hard-coded pixel value from getBoundingClientRect(). It survives responsive headers and fixes :target anchors too. To scroll to something you just rendered, scroll in an effect keyed on the change, or use a callback ref. flushSync works but gives up batching — keep it as an escape hatch. Native smooth scrolling has no duration control, no dependable completion event, no cancel, and no prefers-reduced-motion handling. If none of those matter, don't add a dependency. useScrollIntoView covers exactly those gaps — configurable duration/easing, onScrollFinish, wheel-and-touch cancellation, automatic reduced-motion fallback, plus offset, horizontal axis, an explicit scroll container, and isList for jitter-free list navigation. It resolves the target at call time, so it works in the same handler as the setState that rendered it. useScrollIntoView, useScroll, useScrollLock, and 110+ other SSR-safe, TypeScript-first hooks live in @reactuses/core — one install, tree-shakeable, no dependencies to babysit. npm install @reactuses/core

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News