whether the page shows something now: the tab visible and the element in view
npx shadcn add @ag/use-on-screenAlready installed? Preview an update
npx shadcn add @ag/use-on-screen --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
useOnScreen(element?), whether the page shows something now: the tab visible and, given an element, the element in the viewport (clipping by scrolling ancestors included). Pass the element itself, held by a callback ref (ref={setElement}). One IntersectionObserver and one document listener for the page. False on the server and until the first observation.
also called in view, intersection observer, page visibility
shelf foundations
used by use-poll, relative-time
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { useOnScreen } from "@/registry/base-nova/ui/use-on-screen"
// #region counter
/** Counts the seconds it is shown: scrolled out of its box, or with the tab in the
* background, it stops. A demo stops at 60; a real clock runs on. */
function Counter() {
const [element, setElement] = React.useState<HTMLDivElement | null>(null)
const shown = useOnScreen(element)
const [seconds, setSeconds] = React.useState(0)
React.useEffect(() => {
if (!shown || seconds >= 60) return
const next = window.setTimeout(() => setSeconds((s) => s + 1), 1000)
return () => window.clearTimeout(next)
}, [shown, seconds])
return (
<div
ref={setElement}
className="rounded-lg bg-foreground/5 px-4 py-3 font-mono text-xs tabular-nums"
>
{shown ? "on screen" : "off screen"}, counted {seconds} s
</div>
)
}
// #endregion
// #region tab
/** The tab alone: no element asked about. */
function TabState() {
const visible = useOnScreen()
const [hidden, setHidden] = React.useState(0)
React.useEffect(() => {
if (!visible) setHidden((n) => n + 1)
}, [visible])
return (
<p className="font-mono text-xs tabular-nums">
the tab {visible ? "is visible" : "is hidden"}; hidden {hidden} times so
far
</p>
)
}
// #endregion
export default function UseOnScreenDemo() {
return (
<div className="space-y-8">
<Sample
name="scrolled"
glance
title="counts only while shown"
description="scroll the box: the counter stops once it leaves the viewport, and so does it with the tab in the background."
with="counter"
>
<ScrollArea className="h-48 w-64 space-y-2 px-2 py-5 text-[13px] text-current/55">
{Array.from({ length: 8 }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed run of filler rows
<p key={i}>row {i + 1}, above</p>
))}
<Counter />
{Array.from({ length: 8 }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed run of filler rows
<p key={i}>row {i + 10}, below</p>
))}
</ScrollArea>
</Sample>
<Sample
name="tab"
title="the tab alone"
description="switch to another tab and back."
with="tab"
>
<TabState />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import * as React from "react"
// WHETHER THE PAGE SHOWS SOMETHING NOW: the tab is visible (the Page Visibility API)
// and, given an element, the element is inside the viewport (an IntersectionObserver).
// Work done only to be seen asks it before working: a label's clock (relative-time),
// a poll that asks the server again (use-poll).
//
// useOnScreen() the tab is visible
// useOnScreen(element) the tab is visible and the element is in the viewport
//
// The element is a value, held in state by a callback ref (`ref={setElement}`), never
// a ref object read inside an effect: a node that mounts late or is replaced is
// watched from the render that has it. `null` is an element not mounted yet, so not
// on screen; `undefined` is no element asked about.
//
// One IntersectionObserver for every element on the page and one listener on the
// document, however many labels a table holds. False on the server and until the
// first observation (a frame after mount), so work that must happen once whatever is
// shown (a label's first text) does not wait for it.
type Watch = { seen: boolean | null; calls: Set<(seen: boolean) => void> }
const watched = new Map<Element, Watch>()
let observer: IntersectionObserver | null = null
function watch(element: Element, call: (seen: boolean) => void): () => void {
observer ??= new IntersectionObserver((entries) => {
for (const entry of entries) {
// An entry queued before its element was let go arrives after it.
const w = watched.get(entry.target)
if (!w) continue
w.seen = entry.isIntersecting
for (const c of w.calls) c(entry.isIntersecting)
}
})
let w = watched.get(element)
if (!w) {
w = { seen: null, calls: new Set() }
watched.set(element, w)
observer.observe(element)
}
w.calls.add(call)
// A second watcher of an element already observed gets no first callback of its
// own: it is told what the first one was.
if (w.seen !== null) call(w.seen)
const mine = w
return () => {
mine.calls.delete(call)
if (mine.calls.size > 0) return
watched.delete(element)
observer?.unobserve(element)
}
}
function subscribeVisibility(change: () => void): () => void {
document.addEventListener("visibilitychange", change)
return () => document.removeEventListener("visibilitychange", change)
}
const tabVisible = () => document.visibilityState === "visible"
const onServer = () => false
/** Whether the tab is visible and, given an element, the element is in the viewport. */
export function useOnScreen(element?: Element | null): boolean {
const visible = React.useSyncExternalStore(
subscribeVisibility,
tabVisible,
onServer,
)
const [seen, setSeen] = React.useState(false)
React.useEffect(() => {
if (!element) {
setSeen(false)
return
}
return watch(element, setSeen)
}, [element])
return visible && (element === undefined || seen)
}