Skip to content

Scroll

js
import { 
lockScroll
,
scrollPosition
,
scrollTo
} from '@studiometa/js-toolkit/utils';

Scrolling to a target

The two halves are separate because callers need them separately. scrollPosition() measures and returns; scrollTo() calls it and moves.

A carousel asks which slide is nearest three times for every time it travels — and in v3, asking meant scrolling.

js
import { 
scrollPosition
,
scrollTo
} from '@studiometa/js-toolkit/utils';
const
el
=
document
.
body
;
// Where would we end up? const {
top
,
left
} =
scrollPosition
(
el
, {
align
: 'center' });
// Go there.
scrollTo
(
el
, {
align
: 'center',
offset
: -80 });
scrollTo
('#section', {
axis
: 'y' });
scrollTo
(0);
scrollTo
({
top
: 200 });

scrollPosition

ts
scrollPosition(target: ScrollToTarget, options?: ScrollPositionOptions): ScrollPosition

Measures and returns { top, left } without moving anything.

scrollTo

ts
scrollTo(target: ScrollToTarget, options?: ScrollToOptions): ScrollPosition

Calls scrollPosition() and moves there, returning the same result.

behavior defaults to 'smooth', or to 'instant' when the reader has asked for less motion — so honouring the preference is the default rather than a call site's responsibility.

The target

ts
type ScrollToTarget = string | Element | number | Partial<{ left: number; top: number }>;

A selector, an element, a number, or a position. A number or a position names its own destination, so align does not apply to it.

The options

ts
interface ScrollPositionOptions {
  rootElement?: Element | Window; // what scrolls. Defaults to the window.
  axis?: 'x' | 'y' | 'both'; // which axes a target that names none may move. Defaults to 'y'.
  offset?: number; // pixels to stop short. Defaults to 0.
  align?: ScrollAlign | { x?: ScrollAlign; y?: ScrollAlign };
}

interface ScrollToOptions extends ScrollPositionOptions {
  behavior?: ScrollBehavior;
}

align is 'start' | 'center' | 'end', or one per axis, and it applies to an element target only.

The names are physical, like axis

x and y, not the platform's inline and block. Nothing here maps a writing mode, and borrowing that vocabulary without the mapping would promise what compute-scroll-into-view promises and does not deliver.

What the arithmetic gets right: the viewport is the client box, so a scrollbar gutter is out of it with no special case, and the destination is clamped to the scroll range — centring the first slide asks for a negative offset and gets 0.

SCROLL_AXES

ts
const SCROLL_AXES: Readonly<{ x: 'x'; y: 'y'; both: 'both' }>;

SCROLL_ALIGNMENTS

ts
const SCROLL_ALIGNMENTS: Readonly<{ start: 'start'; center: 'center'; end: 'end' }>;

A dependency was measured and refused

compute-scroll-into-view is 1.4 kB and walks every scrolling ancestor — which is what v4's single rootElement contract declines, and what a boundary option already cancels. Its own source leaves writing modes unimplemented and reads no scroll-padding, so the real delta over core was about twenty lines.

Locking the page

lockScroll

ts
lockScroll(target?: HTMLElement): () => void
js
import { 
lockScroll
} from '@studiometa/js-toolkit/utils';
const
release
=
lockScroll
();
// … the modal is open
release
();

It counts. A modal surface is not alone on a page: a dialog opened from inside a drawer is two holders, and the one that closes first must not put the scroll back under the one still open.

  • The first lock saves the inline value it found.
  • The last release puts exactly that value back.
  • The ones between only move the count.

The release is idempotent, so a surface calls it on close and again on unmount without counting twice — and a component unmounted while open owes the page its scroll, which is what the second call is for.

The count is shared across evaluated copies of the package, through the same runtime slot the focus helpers use, for the same reason: there is one scroll per document.

It is overflow: hidden and nothing else. No paddingRight compensation: scrollbar-gutter: stable is the page's own answer and it does not mis-handle fixed children. iOS Safari remains unreliable, which is the argument for having one function rather than a copy per component.

A native <dialog> needs it too

showModal() gives the top layer, the backdrop, a focus trap and Escape. It does not stop the page behind it scrolling.

See also

MIT Licensed