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 } = useScroll
Comments
No comments yet. Start the discussion.