Skip to content

Scroll

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

scrollPosition and scrollTo

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

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-v4/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 });

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; // 'smooth', or 'instant' when the reader asked for less motion
}

SCROLL_AXES and SCROLL_ALIGNMENTS are the frozen sets behind axis and align.

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 the arithmetic with no special case.
  • The destination is clamped to the scroll range: centring the first slide asks for a negative offset and gets 0.
  • 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.

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.

lockScroll

ts
lockScroll(target?: HTMLElement): () => void
js
import { 
lockScroll
} from '@studiometa/js-toolkit-v4/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.

js
mounted() {
  return this.release ?? undefined;
}

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