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.
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.
Directional entrance
Horizontal reveals mirror automatically when the document direction is RTL.
99.9%
moves without fading — ideal for metrics
Replays on every entry
Scroll past this card and back: it re-animates, while the groups above settle after their first reveal.
Everything below starts hidden — scroll inside the panel and each group reveals as it enters. The first card also reports its own visibility.
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.
Directional entrance
Horizontal reveals mirror automatically when the document direction is RTL.
99.9%
moves without fading — ideal for metrics
Replays on every entry
Scroll past this card and back: it re-animates, while the groups above settle after their first reveal.
The stylesheet is split into two pieces, and styles.css is just the two of them bundled together:
| Import | Type | Default | Description |
|---|---|---|---|
@60fps/ui-inview/core.css | mandatory | — | Wires the engine's data-inview-visible / data-inview-hidden attributes to CSS state. Defines no visible animation by itself. |
@60fps/ui-inview/animations.css | optional | — | The 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
| Attribute | Type | Default | Description |
|---|---|---|---|
data-inview | on a group | — | An observed group. Toggling its visibility cascades to descendants via CSS. |
data-inview-anim | fade | fade-up | fade-left | slide-up | scale-down | — | Opt a node into one animation. Inherits the group visibility state. |
data-inview-repeat | on a group | — | Replay every time the group re-enters, instead of settling after the first reveal. |
data-inview-defer | on a group | — | Skip the enter animation for content already on screen at mount; animate only on later entries. |
data-inview-threshold | number | 0 | Per-group IntersectionObserver threshold override. |
data-inview-margin | string | '0px 0px -10% 0px' | Per-group rootMargin override. |
useInView(ref, options?)
| Option | Type | Default | Description |
|---|---|---|---|
threshold | number | number[] | 0 | Threshold for the root-level `onVisibilityChange`. |
rootMargin | string | — | Root margin for the root-level `onVisibilityChange`. |
root | Element | Document | null | — | IntersectionObserver root for the observed groups. Defaults to the viewport. |
onVisibilityChange | (visible, entry) => void | — | Called 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 property | Type | Default | Description |
|---|---|---|---|
--_inview-state | 0 | 1 | — | 0 = settled/visible, 1 = hidden/"from". Drive any custom formula from this. |
--_inview-opacity | number | — | calc(1 - --_inview-state) — ready to assign to opacity. |
--_inview-translate-x | length | — | Signed by --inview-from-translate-x and mirrored under RTL. |
--_inview-translate-y | length | — | Signed 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-rootinside an@media (scripting: enabled)block, same ascore.css— this keeps the animation inert when JavaScript is disabled, so content is visible by default. - Read
transition-delay/transition-duration/transition-timing-functionfromcore.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-downoverridestransition-durationfor exactly this reason. - You don’t need to handle
prefers-reduced-motionor thedata-inview-disabledescape hatch yourself:core.cssresetsopacity,transformandtransitionon every[data-inview-anim]node under reduced motion, and forces--_inview-stateto0underdata-inview-disabled.