Scrolling

Scrolly

Base scroll detection using IntersectionObserver. Tracks which child element is most in view and updates the `value` binding. Used internally by ScrollyContent, but can be used directly for custom layouts.

Props

PropTypeDefaultDescription
valuenumber—Index of most visible child (bindable)
rootElement | nullnullIntersectionObserver root element
topnumber0Top margin offset in pixels
bottomnumber0Bottom margin offset in pixels
incrementsnumber100Number of threshold steps

Import

import { Scrolly } from '@the-vcsi/scrolly-kit';

Usage

<script>
  import { Scrolly } from '@the-vcsi/scrolly-kit';
  let index = $state(0);
</script>

<Scrolly bind:value={index}>
  <div>Step 1</div>
  <div>Step 2</div>
  <div>Step 3</div>
</Scrolly>

<p>Current step: {index}</p>

Full Source

💡 Components rely on --vcsi-* tokens from tokens.css. You'd need to either need to @import '@the-vcsi/scrolly-kit/styles/tokens.css'; to access the CSS variables or define equivalent variables in your app.css. We also are using types here to provide hints when users are using the components in their project.

<!--
@component
Base scroll detection using IntersectionObserver.

Tracks which child element is most in view and updates the `value` binding.
Used internally by ScrollyContent, but can be used directly for custom layouts.

## Props
- `value` - Index of most visible child (bindable)
- `root` - IntersectionObserver root element (default: null = viewport)
- `top` - Top margin offset in pixels (default: 0)
- `bottom` - Bottom margin offset in pixels (default: 0)
- `increments` - Number of threshold steps (default: 100)

## Usage
Wrap step elements and bind to track active index:
`<Scrolly bind:value={index}>...steps...</Scrolly>`
-->
<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		root?: Element | null;
		top?: number;
		bottom?: number;
		increments?: number;
		value?: number;
		children?: Snippet;
	}

	let {
		root = null,
		top = 0,
		bottom = 0,
		increments = 100,
		value = $bindable<number | undefined>(undefined),
		children
	}: Props = $props();

	let steps: number[] = [];
	let threshold: number[] = [];
	let nodes: NodeListOf<Element> | Element[] = [];
	let intersectionObservers: IntersectionObserver[] = [];
	let container: HTMLDivElement | undefined = $state(undefined);

	function mostInView(): void {
		let maxRatio = 0;
		let maxIndex = 0;
		for (let i = 0; i < steps.length; i++) {
			if (steps[i] > maxRatio) {
				maxRatio = steps[i];
				maxIndex = i;
			}
		}

		if (maxRatio > 0) value = maxIndex;
		else value = undefined;
	}

	function createObserver(node: Element, index: number): void {
		const handleIntersect = (e: IntersectionObserverEntry[]): void => {
			const ratio = e[0].intersectionRatio;
			steps[index] = ratio;
			mostInView();
		};

		const marginTop = top ? top * -1 : 0;
		const marginBottom = bottom ? bottom * -1 : 0;
		const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`;
		const options = { root, rootMargin, threshold };

		if (intersectionObservers[index]) intersectionObservers[index].disconnect();

		const io = new IntersectionObserver(handleIntersect, options);
		io.observe(node);
		intersectionObservers[index] = io;
	}

	function update(): void {
		if (!nodes.length) return;
		nodes.forEach(createObserver);
	}

	$effect(() => {
		if (!container) return;

		for (let i = 0; i < increments + 1; i++) {
			threshold.push(i / increments);
		}
		nodes = container.querySelectorAll(":scope > *:not(iframe)");
		update();
	});

	$effect(() => {
		top;
		bottom;
		update();
	});

</script>

<div bind:this={container}>
	{@render children?.()}
</div>