A lightweight TypeScript utility built on top of the Intersection Observer API that lets you execute callbacks when a DOM element enters or leaves the viewport.
It provides a small abstraction for common tasks like:
- Scroll-triggered animations
- Lazy loading content
- Auto-playing or pausing media
- Infinite scroll triggers
- Analytics visibility tracking
The utility returns a cleanup function so you can stop observing elements when needed.
- Installation
- Framework Imports
- Quick Start
- React
- Vue
- API Reference
- Options
- Examples
- How It Works
- Best Practices
- Notes
- License
Install the package from npm:
npm install @samline/is-visibleThen import it where needed:
import isVisible from '@samline/is-visible'Choose the entrypoint that matches your stack so you only import the implementation you need.
import isVisible from '@samline/is-visible'You can also use the explicit subpath if you prefer:
import isVisible from '@samline/is-visible/vanilla'import { VisibilityObserver, useIsVisible } from '@samline/is-visible/react'React has its own native implementation and types. See docs/react.md for the full guide.
import { VisibilityObserver, useIsVisible } from '@samline/is-visible/vue'Vue has its own native implementation and types. See docs/vue.md for the full guide.
No external runtime dependencies are required. The utility uses the native IntersectionObserver API.
const disconnect = isVisible(targetElement, {
visible: () => console.log('Element is visible'),
notVisible: () => console.log('Element left the viewport'),
inOut: true
})
disconnect()import { useIsVisible } from '@samline/is-visible/react'
export function FadeInSection() {
const { isVisible, ref } = useIsVisible({
inOut: true,
visible: () => console.log('Entered'),
notVisible: () => console.log('Left')
})
return (
<section ref={ref} className={isVisible ? 'is-visible' : 'is-hidden'}>
Content
</section>
)
}<script setup lang="ts">
import { useIsVisible } from '@samline/is-visible/vue'
const { isVisible, target } = useIsVisible({
inOut: true,
visible: () => console.log('Entered'),
notVisible: () => console.log('Left')
})
</script>
<template>
<section ref="target" :class="isVisible ? 'is-visible' : 'is-hidden'">
Content
</section>
</template><script setup lang="ts">
import { VisibilityObserver } from '@samline/is-visible/vue'
</script>
<template>
<VisibilityObserver as="section" :once="true" v-slot="{ isVisible }">
<div :class="isVisible ? 'fade-in' : 'pre-enter'">Animated block</div>
</VisibilityObserver>
</template>import { VisibilityObserver } from '@samline/is-visible/react'
export function AnimatedBlock() {
return (
<VisibilityObserver as='section' once>
{({ isVisible }) => (
<div className={isVisible ? 'fade-in' : 'pre-enter'}>
Animated block
</div>
)}
</VisibilityObserver>
)
}isVisible(element, options?)| Parameter | Type | Description |
|---|---|---|
| element | Element | DOM element to observe |
| options | IsVisibleOptions | Optional configuration object |
() => voidA cleanup function that disconnects the observer.
| Property | Type | Default | Description |
|---|---|---|---|
| inOut | boolean | false | Enables detection when the element leaves the viewport |
| visible | () => void | () => {} | Triggered when the element enters the viewport |
| notVisible | () => void | () => {} | Triggered when the element leaves the viewport when inOut is true |
| once | boolean | false | Stops observing after the first visible trigger |
| options | IntersectionObserverInit | {} | Native IntersectionObserver configuration |
You can pass any native observer options:
{
root: HTMLElement | null
rootMargin: string
threshold: number | number[]
}Example:
options: {
rootMargin: '200px',
threshold: 0.25,
}const box = document.querySelector('.animate-me')
if (box) {
isVisible(box, {
visible: () => {
box.classList.add('fade-in')
},
once: true
})
}const video = document.querySelector('#hero-video') as HTMLVideoElement | null
if (video) {
isVisible(video, {
inOut: true,
visible: () => video.play(),
notVisible: () => video.pause()
})
}const sentinel = document.querySelector('#load-more-trigger')
if (sentinel) {
isVisible(sentinel, {
visible: () => fetchMoreData(),
options: {
rootMargin: '400px',
threshold: 0.1
}
})
}Use the native React entrypoint instead of wiring the Vanilla API inside useEffect manually:
import { useIsVisible } from '@samline/is-visible/react'Use the native Vue entrypoint instead of wiring the Vanilla API inside mounted/watch logic manually:
import { useIsVisible } from '@samline/is-visible/vue'The utility wraps the native IntersectionObserver.
When the observer fires:
- If the element intersects the viewport, visible() runs.
- If inOut is enabled and the element leaves the viewport, notVisible() runs.
If once is enabled, the element is unobserved after the first visibility event.
once: trueThis avoids extra observer work after the first visible transition.
rootMargin: '300px'This can trigger loading before the element becomes visible.
In React, Vue, Astro, or similar environments, always disconnect observers when the component unmounts. The React implementation does this for you.
- Requires IntersectionObserver support in the runtime environment
- Supported by modern browsers
- A polyfill may be required for older browsers
MIT