Smooth scrolling an element into a specific position within a scrollable container, with custom easing, abort support, and promise-based completion tracking.
- Lightweight — the animation comes from easing-scroll, which you can also use on its own when you already know the scroll position
- TypeScript-first — written in TypeScript, ships type declarations
- Dual package — ESM and CJS builds
- Positioning — align to
start,center,end, orneareston both axes - Faithful to the platform — logical
start/end,scroll-paddingandscroll-marginare resolved the wayscrollIntoViewresolves them, RTL containers included - Customizable — bring your own easing function
- Cancellable — abort with AbortSignal
- Promise-based —
awaitcompletion or track partial progress
npm install scroll-into-areapnpm add scroll-into-areaimport { scrollIntoArea } from "scroll-into-area";
const container = document.querySelector("ul")!;
const target = container.querySelector("li")!;
await scrollIntoArea(target, {
container,
y: "center",
duration: 400,
easing: (x) => 1 - Math.pow(1 - x, 3), // easeOutCubic
});Smoothly scrolls target into a specific position within the scrollable container.
Type: Element
The DOM element to scroll into view.
| Option | Type | Default | Description |
|---|---|---|---|
container |
Element |
— | The scrollable container element (required) |
x |
Position |
— | Horizontal alignment: "start", "center", "end", or "nearest" |
y |
Position |
— | Vertical alignment: "start", "center", "end", or "nearest" |
duration |
number |
0 |
Animation duration in milliseconds |
easing |
(t: number) => number |
(t) => t |
Easing function mapping progress (0–1) to eased value |
signal |
AbortSignal |
— | Signal to cancel the animation |
There is no offset option — room for sticky headers comes from the container's
scroll-padding.
Type: "start" | "end" | "center" | "nearest"
| Value | Alignment |
|---|---|
"start" |
Target's start edge meets the container's start edge |
"end" |
Target's end edge meets the container's end edge |
"center" |
Target is centred within the container |
"nearest" |
Scrolls the shortest distance, and not at all if the target is visible |
"start" and "end" are logical, exactly as they are for scrollIntoView's
inline option — see RTL containers below.
Computed positions are clamped to the container's scrollable range, so asking to centre an element near the top of the content simply scrolls as far as it can.
Resolves with a number between 0 and 1 representing animation progress:
| Value | Meaning |
|---|---|
1 |
Animation completed fully |
0 < x < 1 |
Animation was aborted at x progress |
0 |
Animation never started (signal was already aborted) |
- Instant scroll — when
durationis not a finite positive number (0, negative,NaN,Infinity), the element scrolls instantly and resolves1. - No-op — when both
xandyare omitted, resolves1immediately without measuring anything. - Already-aborted signal — resolves
0without scrolling. - Nearest —
"nearest"only scrolls when the target is outside the visible area. If the target is larger than the container, it aligns tostart. - Descendants —
targetdoes not have to be a direct child ofcontainer; positions are computed from bounding rectangles, so any descendant works. - Scrollport — alignment targets the area the container actually scrolls: its padding box, minus any scrollbar. A border or a scrollbar therefore never pushes the target underneath itself.
- SSR — importing the package touches no browser API. Calling
scrollIntoAreaneeds a DOM, so keep the call inside an effect or event handler.
Room for a sticky header or footer is reserved with the container's
scroll-padding,
which is the property the platform already defines for it — and which
scrollIntoView and scroll snapping honour too, so one declaration covers every
way the container gets scrolled:
.container {
scroll-padding-top: 80px; /* clears an 80px sticky header */
}await scrollIntoArea(target, { container, y: "start" });Alignment is then computed against the optimal viewing region — the visible
area inset by that padding — exactly as scrollIntoView does:
"start"and"end"meet the region's edges rather than the container's."center"centres within the region. With padding on one side only, that is half the padding off the container's own centre; with equal padding on both sides it is unchanged."nearest"treats the region as the visible area, so an element hiding under the header counts as off-screen and gets pulled out from under it.
The four edges are independent, so a container with an 80px header and a 40px footer is expressed directly:
.container {
scroll-padding-top: 80px;
scroll-padding-bottom: 40px;
}Percentages resolve against the scrollport, and the initial value auto is
treated as no padding. Should the two edges of an axis add up to more than the
scrollport, both are reduced proportionally so the region collapses to a point
rather than turning inside out. Note that the browser applies scroll-padding
only to its own scrolling — it has no effect on assignments to
scrollTop/scrollLeft — so it is read here explicitly, with
getComputedStyle.
Where scroll-padding reserves room in the container for everything scrolled
into it, scroll-margin
does it for one element, on the element itself. It is honoured here as it is by
scrollIntoView, so a heading already styled for anchor navigation behaves the
same way when scrolled programmatically:
h2 {
scroll-margin-top: 20px;
}The margin grows the box that alignment works with, which means it counts for
"nearest" too: a target whose margin is still covered has not been brought
properly into view, and gets scrolled the rest of the way.
The two compose — a container reserving 80px and a target asking for 20 more lands the target 100px from the scrollport's edge:
.container {
scroll-padding-top: 80px;
}
h2 {
scroll-margin-top: 20px;
}scroll-margin is physical, so in an RTL container it is scroll-margin-right
that applies to x: "start" — the edge that alignment actually lands on. Unlike
scroll-padding, the property accepts lengths only; a percentage is invalid CSS
and has no effect.
Alignment on the x axis is resolved against the container's direction, the
way scrollIntoView({ inline }) is. In a direction: rtl container content
flows from the right, so "start" is the right edge and "end" the left
one:
const container = document.querySelector<HTMLElement>(".rtl-list")!;
// Brings the target's right edge to the container's right edge
await scrollIntoArea(target, { container, x: "start" });scroll-padding stays physical, because that is what the CSS property is: in
the example above the inset comes from scroll-padding-right, the edge the
alignment actually lands on. "center" and "nearest" are symmetric and mean
the same thing in either direction, except that a target too wide to fit falls
back to "start", and so lands against the right edge.
The direction is read from the container with getComputedStyle, so it also
picks up a direction inherited from an ancestor or set by dir="rtl". Reading
it happens only when x is given, after getBoundingClientRect has already
forced layout.
Two limits worth knowing:
- The
yaxis is always top-to-bottom. Vertical writing modes, where the block axis is the horizontal one, are not resolved. - Detection relies on the modern RTL scroll convention, where
scrollLeftruns from0down to-(scrollWidth - clientWidth). Older WebKit reports those offsets as positive and reversed, and is not accounted for.
The default easing is linear (t) => t. Pass any function from easings.net:
await scrollIntoArea(target, {
container,
y: "start",
duration: 600,
// https://easings.net/#easeOutCubic
easing: (x) => 1 - Math.pow(1 - x, 3),
});Use an AbortController to cancel an in-flight animation:
const controller = new AbortController();
setTimeout(() => controller.abort(), 100);
const progress = await scrollIntoArea(target, {
container,
y: "center",
duration: 400,
signal: controller.signal,
});
if (progress < 1) {
console.log(`Aborted at ${Math.round(progress * 100)}%`);
}A reusable hook that scrolls an element into view and cancels on unmount:
import { useEffect, type RefObject } from "react";
import { scrollIntoArea, type Position } from "scroll-into-area";
function useScrollIntoArea(
containerRef: RefObject<HTMLElement | null>,
targetRef: RefObject<Element | null>,
y: Position,
) {
useEffect(() => {
const container = containerRef.current;
const target = targetRef.current;
if (!container || !target) return;
const controller = new AbortController();
scrollIntoArea(target, {
container,
y,
duration: 400,
signal: controller.signal,
easing: (x) => 1 - Math.pow(1 - x, 3),
});
return () => controller.abort();
}, [y]);
}The library exports the Position type for use in your own abstractions:
import { type Position } from "scroll-into-area";
// Position = "start" | "end" | "center" | "nearest"The animation itself is not implemented here. The frame loop, the easing, the
AbortSignal handling and the progress a call resolves with all come from
easing-scroll; this package adds
the geometry on top — working out which scroll position puts a given element
where you asked for it. duration, easing and signal are handed over
untouched, so what its documentation says about them holds here word for word.
The two split along a clean line: if you know the element, use this package;
if you already know the position, use easing-scroll directly and skip the
measuring entirely.
import { easingScroll } from "easing-scroll";
// Back to the top of the page — `window` is a valid target
await easingScroll(window, { top: 0, duration: 400 });
// A known offset inside a container, horizontal only: passing just `left`
// leaves the vertical position alone for the whole animation
await easingScroll(container, { left: 1200, duration: 600 });It has no dependencies of its own, weighs under 600 bytes min+gzip, and works
with any scrollable Element as well as with window.
Install it as a direct dependency rather than reaching for the copy that comes along with this package — strict layouts such as pnpm's will not resolve a package you have not asked for yourself:
npm install easing-scrollscroll-behavior: smooth— this is the one that bites silently. The scroll position is clamped by writing it and reading it back, and a container withscroll-behavior: smoothreports the old value while its own animation runs. The scroll then looks like a no-op and the promise resolves1without moving. Setscroll-behavior: autoon the container (or unset the property) before scrolling it programmatically.- One container — unlike
scrollIntoView, this scrolls the container you pass and nothing else. If the target sits inside a nested scroller, or the container itself is clipped by an ancestor that scrolls, those are left where they are and the target can stay out of sight. Call it once per scroller, innermost first. calc()padding — ascroll-paddingvalue that still mixes units at computed-value time, such ascalc(10% + 20px), counts as no padding. Plain lengths and plain percentages both work.- Concurrent calls — two animations on the same container fight over its
scroll position every frame. Cancel the previous one via its
signalbefore starting the next. prefers-reduced-motion— not handled. Check it yourself and passduration: 0for an instant scroll:const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; await scrollIntoArea(target, { container, y: "center", duration: reduce ? 0 : 400, });
- One extra
scrollevent — clamping writes the target position and restores it within the same task. The final position is unchanged, but the container still emits a singlescrollevent. Worth knowing if you have infinite-scroll, sticky-header or scroll-depth listeners attached. - Overshoot easings — curves that leave the
0–1range (easeOutBack, elastic) are clipped by the scrollable range, so the overshoot is invisible at the very edges of the content. - Geometry is read once — positions are measured up front, before the first frame. If the content reflows mid-animation, the target it is heading for does not follow.