diff --git a/.changeset/scroll-solid2-migration.md b/.changeset/scroll-solid2-migration.md
new file mode 100644
index 000000000..094a0b00f
--- /dev/null
+++ b/.changeset/scroll-solid2-migration.md
@@ -0,0 +1,19 @@
+---
+"@solid-primitives/scroll": major
+---
+
+Migrate to Solid.js v2.0 (beta.10)
+
+## Breaking Changes
+
+**Peer dependency**: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10` are now required.
+
+### `@solid-primitives/scroll`
+
+- `isServer` now imported from `@solidjs/web` (not `solid-js/web`)
+- `onMount` replaced with `onSettled` for post-render position refresh
+- `sharedConfig.context` replaced with `sharedConfig.hydrating` for hydration detection
+- Internal signal pattern replaced: Solid 2.0's `createSignal(fn)` creates a derived signal rather than storing a function value; now uses a version counter to drive memo re-evaluation on scroll events
+- Signal uses `{ ownedWrite: true }` to allow writes from DOM event handlers within reactive scopes
+- Tests updated: `createComputed` removed (no longer in Solid 2.0), replaced with direct reactive reads and `flush()` for synchronous assertions; `createSignal` in tests uses `{ ownedWrite: true }`
+- README: `onMount` example updated to `onSettled`
diff --git a/packages/scroll/LICENSE b/packages/scroll/LICENSE
index 38b41d975..303b92f6b 100644
--- a/packages/scroll/LICENSE
+++ b/packages/scroll/LICENSE
@@ -2,6 +2,21 @@ MIT License
Copyright (c) 2021 Solid Primitives Working Group
+---
+
+The `createPreventScroll` primitive is adapted from
+[solid-prevent-scroll](https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll)
+by Jasmin Noetzli (GiyoMoon), part of the [corvu](https://corvu.dev) project,
+which is itself inspired by [react-remove-scroll](https://github.com/theKashey/react-remove-scroll)
+by Anton Korzunov.
+
+MIT License
+
+Copyright (c) Jasmin Noetzli
+Copyright (c) Anton Korzunov
+
+---
+
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
diff --git a/packages/scroll/README.md b/packages/scroll/README.md
index ed2ed67ac..d3150460b 100644
--- a/packages/scroll/README.md
+++ b/packages/scroll/README.md
@@ -8,10 +8,11 @@
[](https://www.npmjs.com/package/@solid-primitives/scroll)
[](https://github.com/solidjs-community/solid-primitives#contribution-process)
-Reactive primitives to react to element/window scrolling.
+Reactive primitives to react to element/window scrolling, and to prevent scroll outside of a given element.
- [`createScrollPosition`](#createscrollposition) - Reactive primitive providing a store-like object with current scroll position of specified target.
- [`useWindowScrollPosition`](#usewindowscrollposition) - Returns a reactive object with current window scroll position.
+- [`createPreventScroll`](#createpreventscroll) - Prevents scrolling outside of a given element.
## Installation
@@ -45,10 +46,10 @@ createEffect(() => {
```tsx
let ref: HTMLDivElement | undefined;
-// pass as function
+// pass as function — preferred, handles ref population automatically
const scroll = createScrollPosition(() => ref);
-// or wrap with onMount
-onMount(() => {
+// or wrap with onSettled
+onSettled(() => {
const scroll = createScrollPosition(ref!);
});
@@ -119,6 +120,48 @@ createEffect(() => {
Get an `{ x: number, y: number }` object of element/window scroll position.
+## `createPreventScroll`
+
+Prevents scrolling outside of the given element by intercepting `wheel` and `touchmove` events and optionally hiding the `
` scrollbar.
+
+Adapted from [solid-prevent-scroll](https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll) by [Jasmin Noetzli (GiyoMoon)](https://github.com/GiyoMoon), part of the [corvu](https://corvu.dev) project, which is itself inspired by [react-remove-scroll](https://github.com/theKashey/react-remove-scroll) by Anton Korzunov. Adapted for Solid 2.0 and solid-primitives conventions.
+
+### How to use it
+
+```tsx
+import { createPreventScroll } from "@solid-primitives/scroll";
+
+// Prevent all page scroll (no element specified)
+createPreventScroll();
+
+// Prevent scroll outside a specific element
+createPreventScroll({ element: () => myElement });
+
+// Using a signal ref — preferred pattern for JSX refs
+const [ref, setRef] = createSignal();
+createPreventScroll({ element: ref });
+
+;
+
+// Reactive enabled toggle
+const [open, setOpen] = createSignal(false);
+createPreventScroll({ enabled: open });
+```
+
+### Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `element` | `MaybeAccessor` | `undefined` | Allow scroll inside this element. Events outside it are cancelled. |
+| `enabled` | `MaybeAccessor` | `true` | Whether scroll prevention is active. |
+| `hideScrollbar` | `MaybeAccessor` | `true` | Hide the `` scrollbar while active. |
+| `preventScrollbarShift` | `MaybeAccessor` | `true` | Compensate for the hidden scrollbar width to avoid layout shift. |
+| `preventScrollbarShiftMode` | `MaybeAccessor<"padding" \| "margin">` | `"padding"` | Which CSS property to use for the scrollbar shift compensation. |
+| `restoreScrollPosition` | `MaybeAccessor` | `true` | Restore `` scroll position via `window.scrollTo` when disabled. |
+| `allowPinchZoom` | `MaybeAccessor` | `false` | Allow two-finger pinch-zoom gestures. |
+
+Multiple active instances are stacked; only the topmost one installs event listeners. Body styles are shared and only restored once all instances clean up.
+
## Primitive ideas:
_PRs Welcome :)_
diff --git a/packages/scroll/package.json b/packages/scroll/package.json
index 130c6d923..f1e87299c 100644
--- a/packages/scroll/package.json
+++ b/packages/scroll/package.json
@@ -1,10 +1,17 @@
{
"name": "@solid-primitives/scroll",
"version": "2.1.5",
- "description": "Reactive primitives to react to element/window scrolling.",
+ "description": "Reactive primitives to react to element/window scrolling, and to prevent scroll outside of a given element.",
"author": "David Di Biase ",
"contributors": [
- "Damian Tarnawski "
+ {
+ "name": "Damian Tarnawski",
+ "email": "gthetarnav@gmail.com"
+ },
+ {
+ "name": "Jasmin Noetzli",
+ "url": "https://github.com/GiyoMoon"
+ }
],
"license": "MIT",
"homepage": "https://primitives.solidjs.community/package/scroll",
@@ -17,7 +24,8 @@
"stage": 2,
"list": [
"createScrollPosition",
- "useWindowScrollPosition"
+ "useWindowScrollPosition",
+ "createPreventScroll"
],
"category": "Inputs"
},
@@ -49,19 +57,26 @@
"monitor",
"scrollTo",
"scroll",
+ "prevent-scroll",
+ "scroll-lock",
+ "accessibility",
+ "a11y",
"solid",
"primitives"
],
"dependencies": {
"@solid-primitives/event-listener": "workspace:^",
"@solid-primitives/rootless": "workspace:^",
- "@solid-primitives/static-store": "workspace:^"
+ "@solid-primitives/static-store": "workspace:^",
+ "@solid-primitives/utils": "workspace:^"
},
"peerDependencies": {
- "solid-js": "^1.6.12"
+ "@solidjs/web": "^2.0.0-beta.12",
+ "solid-js": "^2.0.0-beta.12"
},
"typesVersions": {},
"devDependencies": {
- "solid-js": "^1.9.7"
+ "@solidjs/web": "2.0.0-beta.12",
+ "solid-js": "2.0.0-beta.12"
}
}
diff --git a/packages/scroll/src/index.ts b/packages/scroll/src/index.ts
index 92f44ef32..92695036b 100644
--- a/packages/scroll/src/index.ts
+++ b/packages/scroll/src/index.ts
@@ -1,108 +1,11 @@
-import { createEventListener } from "@solid-primitives/event-listener";
-import { createHydratableSingletonRoot } from "@solid-primitives/rootless";
-import { createDerivedStaticStore } from "@solid-primitives/static-store";
-import { type Accessor, createSignal, onMount, sharedConfig } from "solid-js";
-import { isServer } from "solid-js/web";
-
-export function getScrollParent(node: Element | null): Element {
- if (isServer) {
- return {} as Element;
- }
- while (node && !isScrollable(node)) {
- node = node.parentElement;
- }
-
- return node || document.scrollingElement || document.documentElement;
-}
-
-export function isScrollable(node: Element): boolean {
- if (isServer) {
- return false;
- }
- const style = window.getComputedStyle(node);
- return /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY);
-}
-
-export type Position = {
- x: number;
- y: number;
-};
-
-const FALLBACK_SCROLL_POSITION = { x: 0, y: 0 } as const satisfies Position;
-
-/**
- * Get an `{ x: number, y: number }` object of element/window scroll position.
- */
-export function getScrollPosition(target: Element | Window | undefined): Position {
- if (isServer || !target) {
- return { ...FALLBACK_SCROLL_POSITION };
- }
- if (target instanceof Window)
- return {
- x: target.scrollX,
- y: target.scrollY,
- };
- return {
- x: target.scrollLeft,
- y: target.scrollTop,
- };
-}
-
-/**
- * Reactive primitive providing a store-like object with current scroll position of specified target.
- * @param target element/window to listen to scroll events. can be a reactive singal.
- * @returns a store-like reactive object `{ x: number, y: number }` of current scroll position of {@link target}
- * @example
- * // target will be window by default
- * const windowScroll = createScrollPosition();
- *
- * createEffect(() => {
- * // returned object is a reactive store-like structure
- * windowScroll.x; // => number
- * windowScroll.y; // => number
- * });
- */
-export function createScrollPosition(
- target?: Accessor | Element | Window,
-): Readonly {
- if (isServer) {
- return FALLBACK_SCROLL_POSITION;
- }
-
- target = target || window;
-
- const isFn = typeof target === "function",
- isHydrating = sharedConfig.context,
- getTargetPos = isFn
- ? () => getScrollPosition((target as Extract)())
- : () => getScrollPosition(target as Element | Window),
- // changing the calc signal will trigger the derived store to update
- [calc, setCalc] = createSignal(isHydrating ? () => FALLBACK_SCROLL_POSITION : getTargetPos, {
- equals: false,
- }),
- trigger = () => setCalc(() => getTargetPos),
- pos = createDerivedStaticStore(() => calc()());
-
- // update the position on mount if we are hydrating (initial pos is null)
- // or if target is a function (which means it could be a ref that will be populated onMount)
- if (isHydrating || isFn) onMount(trigger);
-
- createEventListener(target, "scroll", trigger, { passive: true });
-
- return pos;
-}
-
-/**
- * Returns a reactive object with current window scroll position.
- *
- * This is a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot) primitive.
- *
- * @example
- * const scroll = useWindowScrollPosition();
- * createEffect(() => {
- * console.log(scroll.x, scroll.y)
- * })
- */
-export const useWindowScrollPosition = /*#__PURE__*/ createHydratableSingletonRoot(() =>
- createScrollPosition(isServer ? () => undefined : window),
-);
+export {
+ getScrollParent,
+ isScrollable,
+ getScrollPosition,
+ createScrollPosition,
+ useWindowScrollPosition,
+} from "./scrollPosition.js";
+export type { Position } from "./scrollPosition.js";
+
+export { createPreventScroll } from "./preventScroll.js";
+export type { CreatePreventScrollProps } from "./preventScroll.js";
diff --git a/packages/scroll/src/preventScroll.ts b/packages/scroll/src/preventScroll.ts
new file mode 100644
index 000000000..d5b772201
--- /dev/null
+++ b/packages/scroll/src/preventScroll.ts
@@ -0,0 +1,341 @@
+/**!
+ * Part of this code is adapted from solid-prevent-scroll by Jasmin Noetzli (GiyoMoon),
+ * part of the corvu project. Adapted for Solid 2.0 and solid-primitives conventions.
+ * MIT License, Copyright (c) Jasmin Noetzli
+ *
+ * https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll
+ *
+ * Part of this code is inspired by react-remove-scroll.
+ * MIT License, Copyright (c) Anton Korzunov
+ * https://github.com/theKashey/react-remove-scroll
+ */
+
+import { createEffect, createSignal } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { access, type MaybeAccessor } from "@solid-primitives/utils";
+
+function contains(wrapper: HTMLElement, target: HTMLElement): boolean {
+ if (wrapper.contains(target)) return true;
+ let current: HTMLElement | null = target;
+ while (current) {
+ if (current === wrapper) return true;
+ // @ts-expect-error: _$host is a SolidJS-internal property set on portal roots
+ current = current._$host ?? current.parentElement;
+ }
+ return false;
+}
+
+type Axis = "x" | "y";
+
+export type CreatePreventScrollProps = {
+ /** Element that is allowed to scroll. Wheel/touch events inside it are passed through. *Default = `null`* */
+ element?: MaybeAccessor;
+ /** Whether scroll prevention is active. *Default = `true`* */
+ enabled?: MaybeAccessor;
+ /** Hide the `` scrollbar while active. *Default = `true`* */
+ hideScrollbar?: MaybeAccessor;
+ /** Add padding/margin to `` to compensate for the hidden scrollbar. *Default = `true`* */
+ preventScrollbarShift?: MaybeAccessor;
+ /** Whether to use `padding` or `margin` for the scrollbar shift compensation. *Default = `"padding"`* */
+ preventScrollbarShiftMode?: MaybeAccessor<"padding" | "margin">;
+ /** Restore `` scroll position via `window.scrollTo` when disabled to avoid layout shift. *Default = `true`* */
+ restoreScrollPosition?: MaybeAccessor;
+ /** Allow two-finger pinch-zoom gestures. *Default = `false`* */
+ allowPinchZoom?: MaybeAccessor;
+};
+
+// ─── Module-level stack ───────────────────────────────────────────────────────
+// Tracks active instances; only the topmost one installs wheel/touch handlers.
+
+const [preventScrollStack, setPreventScrollStack] = createSignal([], {
+ ownedWrite: true,
+});
+
+const isActive = (id: string): boolean => {
+ const stack = preventScrollStack();
+ return stack.length > 0 && stack[stack.length - 1] === id;
+};
+
+// ─── Body style tracker ───────────────────────────────────────────────────────
+// Multiple nested instances share a key; the original styles are only restored
+// once the last instance cleans up.
+
+type ActiveStyle = {
+ activeCount: number;
+ originalStyles: Partial;
+ properties: string[];
+};
+
+const activeBodyStyles = new Map();
+
+function applyBodyStyle(
+ key: string,
+ element: HTMLElement,
+ style: Partial,
+ properties: { key: string; value: string }[],
+): () => void {
+ const originalStyles: Partial = {};
+ for (const k in style) {
+ originalStyles[k] = element.style[k as keyof CSSStyleDeclaration] as string;
+ }
+
+ const existing = activeBodyStyles.get(key);
+ if (existing) {
+ existing.activeCount++;
+ } else {
+ activeBodyStyles.set(key, {
+ activeCount: 1,
+ originalStyles,
+ properties: properties.map(p => p.key),
+ });
+ }
+
+ Object.assign(element.style, style);
+ for (const prop of properties) {
+ element.style.setProperty(prop.key, prop.value);
+ }
+
+ return () => {
+ const active = activeBodyStyles.get(key);
+ if (!active) return;
+ if (active.activeCount !== 1) {
+ active.activeCount--;
+ return;
+ }
+ activeBodyStyles.delete(key);
+
+ for (const [k, v] of Object.entries(active.originalStyles)) {
+ (element.style as any)[k] = v;
+ }
+ for (const prop of active.properties) {
+ element.style.removeProperty(prop);
+ }
+ if (element.style.length === 0) {
+ element.removeAttribute("style");
+ }
+ };
+}
+
+// ─── Scroll helpers ───────────────────────────────────────────────────────────
+
+function getScrollDimensions(element: HTMLElement, axis: Axis): [number, number, number] {
+ return axis === "x"
+ ? [element.clientWidth, element.scrollLeft, element.scrollWidth]
+ : [element.clientHeight, element.scrollTop, element.scrollHeight];
+}
+
+function isScrollContainer(element: HTMLElement, axis: Axis): boolean {
+ const styles = getComputedStyle(element);
+ const overflow = axis === "x" ? styles.overflowX : styles.overflowY;
+ return (
+ overflow === "auto" ||
+ overflow === "scroll" ||
+ (element.tagName === "HTML" && overflow === "visible")
+ );
+}
+
+function getScrollAtLocation(
+ location: HTMLElement,
+ axis: Axis,
+ stopAt?: HTMLElement,
+): [number, number] {
+ const directionFactor =
+ axis === "x" && window.getComputedStyle(location).direction === "rtl" ? -1 : 1;
+
+ let currentElement: HTMLElement | undefined = location;
+ let availableScroll = 0;
+ let availableScrollTop = 0;
+ let wrapperReached = false;
+
+ do {
+ const [clientSize, scrollOffset, scrollSize] = getScrollDimensions(currentElement, axis);
+ const scrolled = scrollSize - clientSize - directionFactor * scrollOffset;
+
+ if ((scrollOffset !== 0 || scrolled !== 0) && isScrollContainer(currentElement, axis)) {
+ availableScroll += scrolled;
+ availableScrollTop += scrollOffset;
+ }
+
+ if (currentElement === (stopAt ?? document.documentElement)) {
+ wrapperReached = true;
+ } else {
+ // @ts-expect-error: _$host is a SolidJS-internal property set on portal roots
+ currentElement = currentElement._$host ?? currentElement.parentElement;
+ }
+ } while (currentElement && !wrapperReached);
+
+ return [availableScroll, availableScrollTop];
+}
+
+function wouldScroll(
+ target: HTMLElement,
+ axis: Axis,
+ delta: number,
+ wrapper: HTMLElement | undefined,
+): boolean {
+ const targetInWrapper = wrapper && contains(wrapper, target);
+ const [availableScroll, availableScrollTop] = getScrollAtLocation(
+ target,
+ axis,
+ targetInWrapper ? wrapper : undefined,
+ );
+ // Firefox can report availableScroll as 1 even when no scroll is possible.
+ if (delta > 0 && Math.abs(availableScroll) <= 1) return false;
+ if (delta < 0 && Math.abs(availableScrollTop) < 1) return false;
+ return true;
+}
+
+// ─── Primitive ────────────────────────────────────────────────────────────────
+
+let _nextId = 0;
+
+/**
+ * Prevents scrolling outside of the given element.
+ *
+ * Adapted from [solid-prevent-scroll](https://github.com/corvudev/corvu/tree/main/packages/solid-prevent-scroll)
+ * by Jasmin Noetzli (GiyoMoon), part of the corvu project.
+ */
+export const createPreventScroll = (props: CreatePreventScrollProps = {}): void => {
+ if (isServer) return;
+
+ const id = String(_nextId++);
+
+ let currentTouchStart: [number, number] = [0, 0];
+ let currentTouchStartAxis: Axis | undefined;
+ let currentTouchStartDelta: number | undefined;
+
+ // 1. Manage the active-instance stack.
+ createEffect(
+ () => ({ enabled: access(props.enabled) ?? true }),
+ ({ enabled }) => {
+ if (!enabled) return;
+ setPreventScrollStack(stack => [...stack, id]);
+ return () => setPreventScrollStack(stack => stack.filter(s => s !== id));
+ },
+ );
+
+ // 2. Apply body overflow styles.
+ createEffect(
+ () => ({
+ enabled: access(props.enabled) ?? true,
+ hideScrollbar: access(props.hideScrollbar) ?? true,
+ preventScrollbarShift: access(props.preventScrollbarShift) ?? true,
+ preventScrollbarShiftMode: access(props.preventScrollbarShiftMode) ?? "padding",
+ restoreScrollPosition: access(props.restoreScrollPosition) ?? true,
+ }),
+ ({
+ enabled,
+ hideScrollbar,
+ preventScrollbarShift,
+ preventScrollbarShiftMode,
+ restoreScrollPosition,
+ }) => {
+ if (!enabled || !hideScrollbar) return;
+
+ const { body } = document;
+ const scrollbarWidth = window.innerWidth - body.offsetWidth;
+ const offsetTop = window.scrollY;
+ const offsetLeft = window.scrollX;
+
+ const style: Partial = { overflow: "hidden" };
+ const properties: { key: string; value: string }[] = [];
+
+ if (preventScrollbarShift && scrollbarWidth > 0) {
+ if (preventScrollbarShiftMode === "padding") {
+ style.paddingRight = `calc(${window.getComputedStyle(body).paddingRight} + ${scrollbarWidth}px)`;
+ } else {
+ style.marginRight = `calc(${window.getComputedStyle(body).marginRight} + ${scrollbarWidth}px)`;
+ }
+ properties.push({ key: "--scrollbar-width", value: `${scrollbarWidth}px` });
+ }
+
+ const restoreStyle = applyBodyStyle("prevent-scroll", body, style, properties);
+
+ return () => {
+ restoreStyle();
+ if (restoreScrollPosition && scrollbarWidth > 0) {
+ window.scrollTo(offsetLeft, offsetTop);
+ }
+ };
+ },
+ );
+
+ // 3. Install wheel/touch event listeners (only when this is the topmost instance).
+ createEffect(
+ () => ({
+ active: isActive(id),
+ enabled: access(props.enabled) ?? true,
+ wrapper: access(props.element) ?? undefined,
+ allowPinchZoom: access(props.allowPinchZoom) ?? false,
+ }),
+ ({ active, enabled, wrapper, allowPinchZoom }) => {
+ if (!active || !enabled) return;
+
+ const maybePreventWheel = (event: WheelEvent) => {
+ const target = event.target as HTMLElement;
+ const delta: [number, number] = [event.deltaX, event.deltaY];
+ const axis: Axis = Math.abs(delta[0]) > Math.abs(delta[1]) ? "x" : "y";
+ const axisDelta = axis === "x" ? delta[0] : delta[1];
+
+ const shouldCancel =
+ wrapper && contains(wrapper, target)
+ ? !wouldScroll(target, axis, axisDelta, wrapper)
+ : true;
+
+ if (shouldCancel && event.cancelable) event.preventDefault();
+ };
+
+ const logTouchStart = (event: TouchEvent) => {
+ const touch = event.changedTouches[0];
+ currentTouchStart = touch ? [touch.clientX, touch.clientY] : [0, 0];
+ currentTouchStartAxis = undefined;
+ currentTouchStartDelta = undefined;
+ };
+
+ const maybePreventTouch = (event: TouchEvent) => {
+ const target = event.target as HTMLElement;
+ let shouldCancel: boolean;
+
+ if (event.touches.length === 2) {
+ shouldCancel = !allowPinchZoom;
+ } else {
+ if (currentTouchStartAxis === undefined || currentTouchStartDelta === undefined) {
+ const touch = event.changedTouches[0];
+ const curr: [number, number] = touch ? [touch.clientX, touch.clientY] : [0, 0];
+ const delta: [number, number] = [
+ currentTouchStart[0] - curr[0],
+ currentTouchStart[1] - curr[1],
+ ];
+ const axis: Axis = Math.abs(delta[0]) > Math.abs(delta[1]) ? "x" : "y";
+ currentTouchStartAxis = axis;
+ currentTouchStartDelta = axis === "x" ? delta[0] : delta[1];
+ }
+
+ if ((target as HTMLInputElement).type === "range") {
+ shouldCancel = false;
+ } else {
+ const wouldResultInScroll = wouldScroll(
+ target,
+ currentTouchStartAxis,
+ currentTouchStartDelta,
+ wrapper,
+ );
+ shouldCancel = wrapper && contains(wrapper, target) ? !wouldResultInScroll : true;
+ }
+ }
+
+ if (shouldCancel && event.cancelable) event.preventDefault();
+ };
+
+ document.addEventListener("wheel", maybePreventWheel, { passive: false });
+ document.addEventListener("touchstart", logTouchStart, { passive: false });
+ document.addEventListener("touchmove", maybePreventTouch, { passive: false });
+
+ return () => {
+ document.removeEventListener("wheel", maybePreventWheel);
+ document.removeEventListener("touchstart", logTouchStart);
+ document.removeEventListener("touchmove", maybePreventTouch);
+ };
+ },
+ );
+};
diff --git a/packages/scroll/src/scrollPosition.ts b/packages/scroll/src/scrollPosition.ts
new file mode 100644
index 000000000..b01716fca
--- /dev/null
+++ b/packages/scroll/src/scrollPosition.ts
@@ -0,0 +1,114 @@
+import { type Accessor, createSignal, onSettled, sharedConfig } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { createEventListener } from "@solid-primitives/event-listener";
+import { createHydratableSingletonRoot } from "@solid-primitives/rootless";
+import { createDerivedStaticStore } from "@solid-primitives/static-store";
+
+export function getScrollParent(node: Element | undefined): Element {
+ if (isServer) {
+ return {} as Element;
+ }
+ while (node && !isScrollable(node)) {
+ node = node.parentElement ?? undefined;
+ }
+
+ return node || document.scrollingElement || document.documentElement;
+}
+
+export function isScrollable(node: Element): boolean {
+ if (isServer) {
+ return false;
+ }
+ const style = window.getComputedStyle(node);
+ return /(auto|scroll)/.test(style.overflow + style.overflowX + style.overflowY);
+}
+
+export type Position = {
+ x: number;
+ y: number;
+};
+
+const FALLBACK_SCROLL_POSITION = { x: 0, y: 0 } as const satisfies Position;
+
+/**
+ * Get an `{ x: number, y: number }` object of element/window scroll position.
+ */
+export function getScrollPosition(target: Element | Window | undefined): Position {
+ if (isServer || !target) {
+ return { ...FALLBACK_SCROLL_POSITION };
+ }
+ if (target instanceof Window)
+ return {
+ x: target.scrollX,
+ y: target.scrollY,
+ };
+ return {
+ x: target.scrollLeft,
+ y: target.scrollTop,
+ };
+}
+
+/**
+ * Reactive primitive providing a store-like object with current scroll position of specified target.
+ * @param target element/window to listen to scroll events. can be a reactive singal.
+ * @returns a store-like reactive object `{ x: number, y: number }` of current scroll position of {@link target}
+ * @example
+ * // target will be window by default
+ * const windowScroll = createScrollPosition();
+ *
+ * createEffect(() => {
+ * // returned object is a reactive store-like structure
+ * windowScroll.x; // => number
+ * windowScroll.y; // => number
+ * });
+ */
+export function createScrollPosition(
+ target?: Accessor | Element | Window,
+): Readonly {
+ if (isServer) {
+ return FALLBACK_SCROLL_POSITION;
+ }
+
+ target = target || window;
+
+ const isFn = typeof target === "function";
+ const isHydrating = sharedConfig.hydrating;
+
+ const getPos = (): Position =>
+ getScrollPosition(
+ isFn ? (target as Accessor)() : (target as Element | Window),
+ );
+
+ // Plain integer counter — avoids Solid 2.0 createSignal(fn) derived-signal semantics.
+ // ownedWrite allows writes from DOM event handlers inside reactive scopes.
+ let tick = 1;
+ const [version, setVersion] = createSignal(0, { ownedWrite: true });
+ const trigger = () => void setVersion(tick++);
+
+ const pos = createDerivedStaticStore(() => {
+ const v = version();
+ return isHydrating && v === 0 ? { ...FALLBACK_SCROLL_POSITION } : getPos();
+ });
+
+ // Refs aren't populated until mount; also refreshes position post-hydration.
+ if (isHydrating || isFn) onSettled(trigger);
+
+ createEventListener(target, "scroll", trigger, { passive: true });
+
+ return pos;
+}
+
+/**
+ * Returns a reactive object with current window scroll position.
+ *
+ * This is a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot) primitive.
+ *
+ * @example
+ * const scroll = useWindowScrollPosition();
+ * createEffect(() => {
+ * console.log(scroll.x, scroll.y)
+ * })
+ */
+export const useWindowScrollPosition = /*#__PURE__*/ createHydratableSingletonRoot(() =>
+ createScrollPosition(isServer ? () => undefined : window),
+);
diff --git a/packages/scroll/test/index.test.ts b/packages/scroll/test/index.test.ts
index 7c8534a46..5e028e5bf 100644
--- a/packages/scroll/test/index.test.ts
+++ b/packages/scroll/test/index.test.ts
@@ -1,7 +1,7 @@
-import { createComputed, createRoot, createSignal } from "solid-js";
-import { describe, expect, it } from "vitest";
+import { createRoot, createSignal, flush } from "solid-js";
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
-import { createScrollPosition, getScrollPosition } from "../src/index.js";
+import { createScrollPosition, getScrollPosition, createPreventScroll } from "../src/index.js";
describe("getScrollPosition", () => {
it("no target returns null", () => {
@@ -20,28 +20,25 @@ describe("getScrollPosition", () => {
describe("createScrollPosition", () => {
it("will observe scroll events", () =>
createRoot(dispose => {
- const expectedX = [0, 100, 42];
- const actualX: number[] = [];
- const expectedY = [0, 34, 11];
- const actualY: number[] = [];
-
const target = document.createElement("div");
-
const scroll = createScrollPosition(target);
- createComputed(() => {
- actualX.push(scroll.x);
- actualY.push(scroll.y);
- });
+ expect(scroll.x).toBe(0);
+ expect(scroll.y).toBe(0);
Object.assign(target, { scrollTop: 34, scrollLeft: 100 });
target.dispatchEvent(new Event("scroll"));
+ flush();
+
+ expect(scroll.x).toBe(100);
+ expect(scroll.y).toBe(34);
Object.assign(target, { scrollTop: 11, scrollLeft: 42 });
target.dispatchEvent(new Event("scroll"));
+ flush();
- expect(actualX).toEqual(expectedX);
- expect(actualY).toEqual(expectedY);
+ expect(scroll.x).toBe(42);
+ expect(scroll.y).toBe(11);
dispose();
}));
@@ -54,17 +51,219 @@ describe("createScrollPosition", () => {
const div2 = document.createElement("div");
Object.assign(div2, { scrollTop: 11, scrollLeft: 42 });
- const [target, setTarget] = createSignal(div1);
+ const [target, setTarget] = createSignal(div1, { ownedWrite: true });
const scroll = createScrollPosition(target);
expect(scroll).toEqual({ x: 100, y: 34 });
setTarget(div2);
+ flush();
expect(scroll).toEqual({ x: 42, y: 11 });
setTarget();
+ flush();
expect(scroll).toEqual({ x: 0, y: 0 });
dispose();
}));
});
+
+describe("createPreventScroll", () => {
+ beforeEach(() => {
+ document.body.removeAttribute("style");
+ vi.stubGlobal("scrollTo", vi.fn());
+ });
+
+ afterEach(() => {
+ document.body.removeAttribute("style");
+ vi.unstubAllGlobals();
+ });
+
+ it("sets overflow:hidden on body when enabled", () =>
+ createRoot(dispose => {
+ createPreventScroll();
+ flush();
+
+ expect(document.body.style.overflow).toBe("hidden");
+
+ dispose();
+ }));
+
+ it("restores body style on cleanup", () =>
+ createRoot(dispose => {
+ createPreventScroll();
+ flush();
+
+ expect(document.body.style.overflow).toBe("hidden");
+
+ dispose();
+ expect(document.body.style.overflow).toBe("");
+ }));
+
+ it("does not set overflow when hideScrollbar is false", () =>
+ createRoot(dispose => {
+ createPreventScroll({ hideScrollbar: false });
+ flush();
+
+ expect(document.body.style.overflow).not.toBe("hidden");
+
+ dispose();
+ }));
+
+ it("does not apply styles when enabled is false", () =>
+ createRoot(dispose => {
+ createPreventScroll({ enabled: false });
+ flush();
+
+ expect(document.body.style.overflow).not.toBe("hidden");
+
+ dispose();
+ }));
+
+ it("reacts to enabled signal", () =>
+ createRoot(dispose => {
+ const [enabled, setEnabled] = createSignal(false, { ownedWrite: true });
+ createPreventScroll({ enabled });
+ flush();
+
+ expect(document.body.style.overflow).not.toBe("hidden");
+
+ setEnabled(true);
+ flush();
+ expect(document.body.style.overflow).toBe("hidden");
+
+ setEnabled(false);
+ flush();
+ expect(document.body.style.overflow).not.toBe("hidden");
+
+ dispose();
+ }));
+
+ it("installs wheel event listener when enabled", () =>
+ createRoot(dispose => {
+ const addSpy = vi.spyOn(document, "addEventListener");
+
+ createPreventScroll();
+ flush();
+
+ const wheelCalls = addSpy.mock.calls.filter(([event]) => event === "wheel");
+ expect(wheelCalls.length).toBeGreaterThan(0);
+
+ addSpy.mockRestore();
+ dispose();
+ }));
+
+ it("removes wheel event listener on cleanup", () =>
+ createRoot(dispose => {
+ const removeSpy = vi.spyOn(document, "removeEventListener");
+
+ createPreventScroll();
+ flush();
+ dispose();
+
+ const wheelCalls = removeSpy.mock.calls.filter(([event]) => event === "wheel");
+ expect(wheelCalls.length).toBeGreaterThan(0);
+
+ removeSpy.mockRestore();
+ }));
+
+ it("prevents wheel events outside element", () =>
+ createRoot(dispose => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+
+ createPreventScroll({ element: container });
+ flush();
+
+ const outside = document.createElement("div");
+ document.body.appendChild(outside);
+
+ const event = new WheelEvent("wheel", {
+ bubbles: true,
+ cancelable: true,
+ deltaY: 100,
+ target: outside,
+ });
+ Object.defineProperty(event, "target", { value: outside, configurable: true });
+ document.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+
+ document.body.removeChild(container);
+ document.body.removeChild(outside);
+ dispose();
+ }));
+
+ it("accepts a signal ref as element — wrapper updates reactively", () =>
+ createRoot(dispose => {
+ const container = document.createElement("div");
+ const [ref, setRef] = createSignal(undefined, {
+ ownedWrite: true,
+ });
+
+ const addSpy = vi.spyOn(document, "addEventListener");
+
+ createPreventScroll({ element: ref });
+ flush();
+
+ const wheelCallsAfterInit = addSpy.mock.calls.filter(([e]) => e === "wheel").length;
+ expect(wheelCallsAfterInit).toBe(1);
+
+ // Changing the signal causes the effect to re-run and reinstall listeners with the new wrapper
+ setRef(container);
+ flush();
+
+ const wheelCallsAfterUpdate = addSpy.mock.calls.filter(([e]) => e === "wheel").length;
+ expect(wheelCallsAfterUpdate).toBe(2);
+
+ addSpy.mockRestore();
+ dispose();
+ }));
+
+ it("stacks multiple instances — only top one handles events", () =>
+ createRoot(dispose1 => {
+ createPreventScroll();
+ flush();
+
+ const addSpy = vi.spyOn(document, "addEventListener");
+
+ createRoot(dispose2 => {
+ createPreventScroll();
+ flush();
+
+ // Both instances active; second is on top
+ expect(document.body.style.overflow).toBe("hidden");
+
+ dispose2();
+ });
+
+ flush();
+ // First instance should still keep body locked
+ expect(document.body.style.overflow).toBe("hidden");
+
+ addSpy.mockRestore();
+ dispose1();
+ }));
+
+ it("restores body style only after all stacked instances clean up", () =>
+ createRoot(dispose1 => {
+ createPreventScroll();
+ flush();
+
+ const cleanup2 = createRoot(dispose2 => {
+ createPreventScroll();
+ flush();
+ return dispose2;
+ });
+
+ expect(document.body.style.overflow).toBe("hidden");
+
+ cleanup2();
+ flush();
+ // First instance still active
+ expect(document.body.style.overflow).toBe("hidden");
+
+ dispose1();
+ expect(document.body.style.overflow).toBe("");
+ }));
+});
diff --git a/packages/scroll/test/server.test.ts b/packages/scroll/test/server.test.ts
index 8427402c2..c8f509e41 100644
--- a/packages/scroll/test/server.test.ts
+++ b/packages/scroll/test/server.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { createScrollPosition, getScrollPosition, useWindowScrollPosition } from "../src/index.js";
+import {
+ createScrollPosition,
+ createPreventScroll,
+ getScrollPosition,
+ useWindowScrollPosition,
+} from "../src/index.js";
describe("getScrollPosition", () => {
it("returns null", () => {
@@ -18,3 +23,10 @@ describe("useWindowScrollPosition", () => {
expect(useWindowScrollPosition()).toEqual({ x: 0, y: 0 });
});
});
+
+describe("createPreventScroll", () => {
+ it("does nothing on the server", () => {
+ expect(() => createPreventScroll()).not.toThrow();
+ expect(() => createPreventScroll({ enabled: true, hideScrollbar: true })).not.toThrow();
+ });
+});
diff --git a/packages/scroll/tsconfig.json b/packages/scroll/tsconfig.json
index f696f1546..536eac32d 100644
--- a/packages/scroll/tsconfig.json
+++ b/packages/scroll/tsconfig.json
@@ -14,6 +14,9 @@
},
{
"path": "../static-store"
+ },
+ {
+ "path": "../utils"
}
],
"include": [
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index df4a9c2a6..8929b6345 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -406,6 +406,23 @@ export function pipe(a: (raw: string) => A, b: (a: A) => B): (raw: string)
return (raw: string): B => b(a(raw));
}
+// ─── DOM helpers ─────────────────────────────────────────────────────────────
+
+/**
+ * Check if a wrapper element contains a target element.
+ * Portal-aware: follows SolidJS `_$host` links so elements rendered inside
+ * a `` are correctly treated as children of the portal's anchor.
+ */
+export const contains = (wrapper: HTMLElement, target: HTMLElement): boolean => {
+ if (wrapper.contains(target)) return true;
+ let current: HTMLElement | null = target;
+ while (current) {
+ if (current === wrapper) return true;
+ // @ts-expect-error: _$host is a SolidJS-internal property set on portal roots
+ current = current._$host ?? current.parentElement;
+ }
+ return false;
+};
/**
* Wraps a setter function of any signal or store
*
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 696c52a39..80f5f00e1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -868,10 +868,16 @@ importers:
'@solid-primitives/static-store':
specifier: workspace:^
version: link:../static-store
+ '@solid-primitives/utils':
+ specifier: workspace:^
+ version: link:../utils
devDependencies:
+ '@solidjs/web':
+ specifier: 2.0.0-beta.12
+ version: 2.0.0-beta.12(solid-js@2.0.0-beta.12)
solid-js:
- specifier: ^1.9.7
- version: 1.9.7
+ specifier: 2.0.0-beta.12
+ version: 2.0.0-beta.12
packages/selection:
devDependencies: