Skip to content
60fps/ui
@60fps/ui-inviewv0.0.3

InView

InViewEngine observes the groups inside a root element and toggles a single attribute as each one enters or leaves the viewport. All animation is pure CSS — the engine never touches style, it only flips data-inview-visible / data-inview-hidden, and the stylesheet does the rest through custom properties. That keeps the JavaScript tiny and the same across every framework.

Everything below starts hidden — scroll inside the panel and each group reveals as it enters. The first card also reports its own visibility.

↓ scroll down to reveal
onVisibilityChangeOut of view · 0×

This card reports its own visibility

The engine emits a visibility event as the root enters and leaves the panel. Scroll it away and back to watch the counter climb.

fade-left

Directional entrance

Horizontal reveals mirror automatically when the document direction is RTL.

slide-up

99.9%

moves without fading — ideal for metrics

scale-down
settles into place
data-inview-repeat

Replays on every entry

Scroll past this card and back: it re-animates, while the groups above settle after their first reveal.

↑ scroll back up to replay

Everything below starts hidden — scroll inside the panel and each group reveals as it enters. The first card also reports its own visibility.

↓ scroll down to reveal
onVisibilityChange Out of view · 0×

This card reports its own visibility

The engine emits a visibility event as the root enters and leaves the panel. Scroll it away and back to watch the counter climb.

fade-left

Directional entrance

Horizontal reveals mirror automatically when the document direction is RTL.

slide-up

99.9%

moves without fading — ideal for metrics

scale-down
settles into place
data-inview-repeat

Replays on every entry

Scroll past this card and back: it re-animates, while the groups above settle after their first reveal.

↑ scroll back up to replay

The stylesheet is split into two pieces, and styles.css is just the two of them bundled together:

ImportTypeDefaultDescription
@60fps/ui-inview/core.cssmandatoryWires the engine's data-inview-visible / data-inview-hidden attributes to CSS state. Defines no visible animation by itself.
@60fps/ui-inview/animations.cssoptionalThe default fade / fade-up / fade-left / slide-up / scale-down presets, built on top of core.css.

Import core.css on its own — skipping animations.css — if you plan to write every data-inview-anim rule yourself; see Building custom animations below.

Usage

Give the root class="inview-root" and call useInView(ref) — the engine marks the root with data-inview-scope itself. Inside, every data-inview group shares one visibility state, and each node opts into an animation with data-inview-anim:

import { useRef } from 'react';
import { useInView } from '@60fps/ui-react/inview-engine';
import { inViewAnimStyle } from '@60fps/ui-inview';

function Section() {
	const ref = useRef<HTMLDivElement>(null);
	useInView(ref);

	return (
		<div ref={ref} className="inview-root">
			<section data-inview>
				<h2 data-inview-anim="fade-up">Reveal me</h2>
				<p data-inview-anim="fade" style={inViewAnimStyle({ delay: 120 })}>
					…then me, a touch later.
				</p>
			</section>
		</div>
	);
}
<script setup lang="ts">
import { ref } from 'vue';
import { useInView } from '@60fps/ui-vue/inview-engine';
import { inViewAnimStyle } from '@60fps/ui-inview';

const root = ref<HTMLElement | null>(null);
useInView(root);
</script>

<template>
	<div ref="root" class="inview-root">
		<section data-inview>
			<h2 data-inview-anim="fade-up">Reveal me</h2>
			<p data-inview-anim="fade" :style="inViewAnimStyle({ delay: 120 })">…then me, a touch later.</p>
		</section>
	</div>
</template>

The markup contract

AttributeTypeDefaultDescription
data-inviewon a groupAn observed group. Toggling its visibility cascades to descendants via CSS.
data-inview-animfade | fade-up | fade-left | slide-up | scale-downOpt a node into one animation. Inherits the group visibility state.
data-inview-repeaton a groupReplay every time the group re-enters, instead of settling after the first reveal.
data-inview-deferon a groupSkip the enter animation for content already on screen at mount; animate only on later entries.
data-inview-thresholdnumber0Per-group IntersectionObserver threshold override.
data-inview-marginstring'0px 0px -10% 0px'Per-group rootMargin override.

useInView(ref, options?)

OptionTypeDefaultDescription
thresholdnumber | number[]0Threshold for the root-level `onVisibilityChange`.
rootMarginstringRoot margin for the root-level `onVisibilityChange`.
rootElement | Document | nullIntersectionObserver root for the observed groups. Defaults to the viewport.
onVisibilityChange(visible, entry) => voidCalled as the root element enters and leaves the viewport. Opts the root into observation.

Tuning with inViewAnimStyle

For per-node delays, offsets or one-off overrides, inViewAnimStyle returns a plain style object (React style or Vue :style) that sets the underlying custom properties:

inViewAnimStyle({ delay: 120, duration: 600, fromTranslateY: '28px' });
// → { '--inview-delay': '120ms', '--inview-duration': '600ms', '--inview-from-translate-y': '28px' }

Building custom animations

The five built-in presets are just CSS rules targeting [data-inview-anim="…"] — nothing about them is special-cased in the engine. core.css does the only part that matters: it derives a --_inview-state custom property (1 while hidden/“from”, 0 while visible/“to”) from data-inview-visible / data-inview-hidden, plus a few shared helpers built from it:

Custom propertyTypeDefaultDescription
--_inview-state0 | 10 = settled/visible, 1 = hidden/"from". Drive any custom formula from this.
--_inview-opacitynumbercalc(1 - --_inview-state) — ready to assign to opacity.
--_inview-translate-xlengthSigned by --inview-from-translate-x and mirrored under RTL.
--_inview-translate-ylengthSigned by --inview-from-translate-y.

To ship your own animation instead of (or alongside) the presets, import only core.css, then add a rule scoped to .inview-root and your own data-inview-anim value:

/* my-inview-anims.css */
.inview-root {
	@media (scripting: enabled) {
		/* data-inview-anim="blur-in" */
		& [data-inview-anim='blur-in'] {
			opacity: var(--_inview-opacity);
			filter: blur(calc(var(--_inview-state, 1) * 8px));
			transition-property: opacity, filter;
		}

		/* data-inview-anim="rotate-in" — a formula that doesn't reuse any shared helper */
		& [data-inview-anim='rotate-in'] {
			opacity: var(--_inview-opacity);
			transform: rotate(calc(var(--_inview-state, 1) * -6deg));
			transition-property: opacity, transform;
		}
	}
}
<p data-inview-anim="blur-in">Custom reveal</p>

A few rules keep custom animations consistent with the built-ins:

  • Nest under .inview-root inside an @media (scripting: enabled) block, same as core.css — this keeps the animation inert when JavaScript is disabled, so content is visible by default.
  • Read transition-delay / transition-duration / transition-timing-function from core.css’s shared block (they’re already set on [data-inview-anim]) instead of redeclaring them, unless a specific animation needs its own timing — scale-down overrides transition-duration for exactly this reason.
  • You don’t need to handle prefers-reduced-motion or the data-inview-disabled escape hatch yourself: core.css resets opacity, transform and transition on every [data-inview-anim] node under reduced motion, and forces --_inview-state to 0 under data-inview-disabled.