← ui

use-on-screen

ui

whether the page shows something now: the tab visible and the element in view

npx shadcn add @ag/use-on-screen
Already installed? Preview an update
npx shadcn add @ag/use-on-screen --dry-run

Your 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.
Updated 2026-10-12 · release notes

also called in view, intersection observer, page visibility

shelf foundations

used by use-poll, relative-time

the demo above, verbatim

use-on-screen.demo.tsx
"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-on-screen.ts
"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)
}