Skip to content

Reveal Controller ​

createRevealController(options?) — scans the DOM for elements matching a selector and toggles a class/attribute on each as it enters the viewport, without a useElementVisibility()/observe() call per element. Built entirely on createVisibilityEngine's pooled observer — a MutationObserver (default: watching document.body) picks up elements matching selector that show up later (e.g. a v-for block rendered after an async fetch) and unobserves elements removed from the DOM.

ts
function createRevealController(options?: RevealControllerOptions): RevealController
ts
import { createRevealController } from '@macrulez/inview-core'

const controller = createRevealController({
  selector: '[data-reveal], .reveal', // the default
  stagger: { step: 70, max: 4 },
})

// later, e.g. on app teardown
controller.destroy()
css
.reveal {
  opacity: 0;
  transition: opacity 0.4s ease-out;
  transition-delay: var(--reveal-delay, 0ms);
}
.reveal.in {
  opacity: 1;
}

Options ​

RevealControllerOptions ​

FieldTypeDefault
selectorstring'[data-reveal], .reveal'
activeClassstring | null'in' — null disables the class toggle
activeAttributestring | nullnull — a boolean data-attribute set alongside activeClass
oncebooleantrue — unlike useElementVisibility's own false; reveal effects almost always want this
thresholdnumber | number[]0
rootMarginstring'0px'
rootElement | nullnull
staggerRevealStaggerOptions | false{} — pass false to disable
watchMutationsbooleantrue
watchRootElementdocument.body
onEnter / onLeave(el: Element, info: IntersectionInfo) => void— same info shape as Visibility Engine, with the matched element as the first argument since one controller tracks many
poolObserverPoolshared default pool

RevealStaggerOptions ​

Extends StaggerDelayOptions (step, max, unit, mode) with two extra fields:

FieldTypeDefault
cssVarstring | null'--reveal-delay' — pass null to skip writing the CSS var entirely
applyInlineDelaybooleantrue

By default the computed delay is written both as the --reveal-delay CSS var, via bindCSSVar, and as an inline transition-delay style on the element itself. The inline style is what actually wins the cascade — a .card { transition: ... } rule declared later (a hover transition, say) would otherwise silently override a transition-delay: var(--reveal-delay) CSS rule without needing !important. The CSS var keeps being written too, since unlike the inline style it's inheritable — needed for a descendant selector like .reveal > .icon { transition-delay: var(--reveal-delay) }. Pass applyInlineDelay: false to only write the CSS var, or cssVar: null to only set the inline style.

The stagger delay is skipped entirely — no CSS var, no inline style, no cleanup listener — whenever prefersReducedMotion() is true, the same as passing stagger: false explicitly. The class/attribute toggle itself still happens normally either way; it's only the staggered timing that's a motion concern.

Per-element overrides ​

Every controller-wide option can be overridden on an individual element via a matching data-reveal-* attribute — the controller's own value only applies where the element doesn't say otherwise.

AttributeOverrides
data-reveal-once="false"once
data-reveal-threshold="0,0.5"threshold — comma-separated for an array
data-reveal-root-margin="-10%"rootMargin
data-reveal-class="visible"activeClass, for that element only
data-reveal-attribute="active"activeAttribute, for that element only
data-reveal-delay="140"an explicit stagger delay in ms, skipping the computed one entirely
data-reveal-group="grid-a"counts this element's stagger index within the named group instead of globally
html
<div class="reveal" data-reveal-once="false" data-reveal-threshold="0.4">
  Toggles back and forth instead of stopping after the first reveal.
</div>

Return value ​

refresh(selector?) ​

(selector?: string) => void

Re-scans for elements not yet observed. Rarely needed — watchMutations (on by default) already does this automatically — useful right after a synchronous DOM change if you've turned watchMutations off. Pass a selector to scan for something other than the controller's own selector for this one call (a narrower one scoped to a subtree you just rendered, say) without changing what watchMutations itself looks for going forward.

observe(el) ​

(el: Element) => void

Starts observing el directly, bypassing the selector scan entirely — for an element you already have a reference to and don't want to rely on a selector match for. A no-op if el is already observed.

unobserve(el) ​

(el: Element) => void

Stops observing el, the same as if it had been removed from the DOM.

destroy() ​

() => void

Disconnects the MutationObserver and unobserves every currently tracked element.

Example: a page-wide reveal pass ​

The exact scenario this exists for — ~100 elements marked with a class across several dynamically rendered lists, some appearing after an async fetch:

ts
import { createRevealController } from '@macrulez/inview-core'

const controller = createRevealController({
  stagger: { step: 60, max: 4 },
})
vue
<template>
  <!-- rendered before the fetch resolves — createRevealController's
       MutationObserver picks these up automatically once they render -->
  <div v-for="item in items" :key="item.id" class="reveal">
    {{ item.title }}
  </div>
</template>

No wrapper component per list item, no per-element useElementVisibility() call — the controller finds .reveal elements on its own, now and later.

Lifecycle wrapper: useRevealController ​

Vue and React both have a thin lifecycle wrapper that removes the manual onMounted/useEffect + destroy() boilerplate a page-wide controller otherwise needs at every call site — creates the controller on mount, destroys it automatically on unmount, and returns the same refresh/observe/unobserve/destroy shape:

ts
function useRevealController(options?: RevealControllerOptions): RevealController
vue
<script setup lang="ts">
import { useRevealController } from '@macrulez/inview-vue'

useRevealController({ stagger: { step: 70, max: 4 } })
</script>
tsx
import { useRevealController } from '@macrulez/inview-react'

function App() {
  useRevealController({ stagger: { step: 70, max: 4 } })
  return <Page />
}

options are captured once, at mount, and used for the controller's entire lifetime — the same one-shot option model createRevealController itself has, not something that reacts to a changing options object on every re-render/re-run.

When you'd reach for this instead of v-reveal/<InView> ​

v-reveal and <InView> (Vue) give you per-element control — a different onEnter, or the reactive isVisible value back in the template. createRevealController is for markup you'd rather not wire up element-by-element at all: one call covers the whole page (or a container), including elements that don't exist in the DOM yet. It's also the only one of the three that works the same way with no framework at all — see React Hooks for the React side (useRevealController above, or createRevealController called directly).