useInView

Tracks whether an element is within the viewport.

useInView is a tiny hook that tracks whether an element is within the viewport. It works with any element.

<script lang="ts">
	import { useInView } from "motion-sv";

	let element = $state<HTMLElement>();
	const inView = useInView(() => element);
</script>

<div bind:this={element}>{inView.current ? "In view" : "Out of view"}</div>

Usage

Import useInView from motion-sv:

import { useInView } from "motion-sv";

Pass useInView a function that returns the element to track. The element isn't bound yet when the component's <script> runs, so useInView reads it through the function, and starts tracking once it's there.

<script lang="ts">
	import { useInView } from "motion-sv";

	let element = $state<HTMLElement>();
	const inView = useInView(() => element);
</script>

<div bind:this={element}></div>

To track a motion component, bind its element with bind:ref:

<script lang="ts">
	import { motion, useInView } from "motion-sv";

	let element = $state<HTMLElement | SVGElement | null>(null);
	const inView = useInView(() => element);
</script>

<motion.div bind:ref={element} />

inView.current is false while the element is outside the viewport, and becomes true when it enters. It's reactive, so anything that reads it, in markup or in $derived and $effect, updates with it.

<motion.div animate={{ opacity: inView.current ? 1 : 0 }} />

If the function returns a different element later, useInView tracks the new one instead.

Effects

To run code when the element enters or leaves the viewport, read inView.current in an $effect:

<script lang="ts">
	$effect(() => {
		console.log("Element is in view: ", inView.current);
	});
</script>

To animate elements with useAnimate as they scroll into view:

<script lang="ts">
	import { stagger, useAnimate, useInView } from "motion-sv";

	const [scope, animate] = useAnimate();
	const inView = useInView(
		() => scope.current,
		() => ({ once: true })
	);

	$effect(() => {
		if (inView.current) {
			animate("li", { opacity: 1, y: 0 }, { delay: stagger(0.1) });
		}
	});
</script>

Options

Pass options as a second function, so they can change too:

const inView = useInView(
	() => element,
	() => ({ once: true })
);

root

By default, useInView tracks the element as it enters and leaves the window's viewport. Set root to a scrollable ancestor to use that element's viewport instead.

<script lang="ts">
	import { useInView } from "motion-sv";

	let container = $state<HTMLElement>();
	let element = $state<HTMLElement>();

	const inView = useInView(
		() => element,
		() => ({ root: container })
	);
</script>

<div bind:this={container} style="overflow: scroll">
	<div bind:this={element}></div>
</div>
<script lang="ts">
	import { motion, useInView } from "motion-sv";

	let container = $state<HTMLElement>();
	let target = $state<HTMLElement>();

	const inView = useInView(
		() => target,
		() => ({ root: container, amount: 0.5 })
	);
</script>

<div class="frame">
	<p class="status">{inView.current ? "In view" : "Out of view"}</p>
	<div class="scroller" bind:this={container}>
		<p class="hint">Scroll down</p>
		<div class="target" bind:this={target}>
			<motion.div
				class="box"
				initial={false}
				animate={{ scale: inView.current ? 1 : 0.5, opacity: inView.current ? 1 : 0.2 }}
				transition={{ type: "spring", bounce: 0.4 }}
			/>
		</div>
	</div>
</div>

<style>
	.frame {
		position: relative;
		width: min(320px, 100%);
	}

	.status {
		position: absolute;
		top: 12px;
		right: 12px;
		z-index: 1;
		margin: 0;
		padding: 4px 10px;
		border-radius: 999px;
		background: rgb(12 220 247 / 0.2);
		font-size: 14px;
	}

	.scroller {
		height: 240px;
		border-radius: 16px;
		background: rgb(12 220 247 / 0.08);
		overflow-y: auto;
	}

	.hint {
		margin: 0;
		padding: 16px;
	}

	.target {
		width: 120px;
		height: 120px;
		margin: 320px auto 40px;
	}

	.target :global(.box) {
		width: 100%;
		height: 100%;
		border-radius: 20px;
		background: #0cdcf7;
	}
</style>

margin

Default: "0px"

A margin to add to the viewport, to grow or shrink the area that counts as in view. Use up to four values, in the order top, right, bottom, left, like "0px -20px 0px 100px".

const inView = useInView(
	() => element,
	() => ({ margin: "0px 100px -50px 0px" })
);

once

Default: false

If true, useInView stops tracking the element once it has entered the viewport, and current stays true.

const inView = useInView(
	() => element,
	() => ({ once: true })
);

initial

Default: false

The value of current until the element first enters the viewport.

const inView = useInView(
	() => element,
	() => ({ initial: true })
);

amount

Default: "some"

How much of the element must be inside the viewport to count as in view. Either "some", "all", or a number between 0 and 1.

const inView = useInView(
	() => element,
	() => ({ amount: 0.5 })
);

Example

These cards animate in as they scroll into view. They're motion components, so they use the whileInView prop, which detects when they enter the viewport the same way useInView does:

<script lang="ts">
	import { motion, type Variants } from "motion-sv";

	const cardVariants: Variants = {
		offscreen: {
			y: 300,
		},
		onscreen: {
			y: 50,
			rotate: -10,
			transition: {
				type: "spring",
				bounce: 0.4,
				duration: 0.8,
			},
		},
	};

	const hue = (h: number) => `hsl(${h}, 100%, 50%)`;

	const food: [string, number, number][] = [
		["๐Ÿ…", 340, 10],
		["๐ŸŠ", 20, 40],
		["๐Ÿ‹", 60, 90],
		["๐Ÿ", 80, 120],
		["๐Ÿ", 100, 140],
		["๐Ÿซ", 205, 245],
		["๐Ÿ†", 260, 290],
		["๐Ÿ‡", 290, 320],
	];

	// The cards scroll inside this box, so it's the viewport they come into view in
	let scroller = $state<HTMLElement>();
</script>

<div class="scroller" bind:this={scroller}>
	<div class="container">
		{#each food as [emoji, hueA, hueB] (emoji)}
			<motion.div
				class="card-container"
				initial="offscreen"
				whileInView="onscreen"
				inViewOptions={{ root: scroller, amount: 0.8 }}
			>
				<div class="splash" style:background="linear-gradient(306deg, {hue(hueA)}, {hue(hueB)})"></div>
				<motion.div class="card" variants={cardVariants}>
					{emoji}
				</motion.div>
			</motion.div>
		{/each}
	</div>
</div>

<style>
	.scroller {
		box-sizing: border-box;
		width: 100%;
		height: 500px;
		overflow-y: auto;
		border: 1px solid rgb(127 127 127 / 0.25);
		border-radius: 12px;
	}

	.container {
		width: 100%;
		max-width: 500px;
		margin: 100px auto;
		padding-bottom: 100px;
	}

	.scroller :global(.card-container) {
		position: relative;
		display: flex;
		justify-content: center;
		align-items: center;
		overflow: hidden;
		padding-top: 20px;
		margin-bottom: -120px;
	}

	.splash {
		position: absolute;
		inset: 0;
		clip-path: path(
			"M 0 303.5 C 0 292.454 8.995 285.101 20 283.5 L 460 219.5 C 470.085 218.033 480 228.454 480 239.5 L 500 430 C 500 441.046 491.046 450 480 450 L 20 450 C 8.954 450 0 441.046 0 430 Z"
		);
	}

	.scroller :global(.card) {
		display: flex;
		justify-content: center;
		align-items: center;
		width: 300px;
		height: 430px;
		border-radius: 20px;
		background: #f5f5f5;
		font-size: 164px;
		box-shadow:
			0 0 1px hsl(0deg 0% 0% / 0.075),
			0 0 2px hsl(0deg 0% 0% / 0.075),
			0 0 4px hsl(0deg 0% 0% / 0.075),
			0 0 8px hsl(0deg 0% 0% / 0.075),
			0 0 16px hsl(0deg 0% 0% / 0.075);
		transform-origin: 10% 60%;
	}
</style>