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
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Index of most visible child (bindable) |
root | Element | null | null | IntersectionObserver root element |
top | number | 0 | Top margin offset in pixels |
bottom | number | 0 | Bottom margin offset in pixels |
increments | number | 100 | Number 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>