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.
function createRevealController(options?: RevealControllerOptions): RevealControllerimport { 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().reveal {
opacity: 0;
transition: opacity 0.4s ease-out;
transition-delay: var(--reveal-delay, 0ms);
}
.reveal.in {
opacity: 1;
}Options
RevealControllerOptions
| Field | Type | Default |
|---|---|---|
selector | string | '[data-reveal], .reveal' |
activeClass | string | null | 'in' — null disables the class toggle |
activeAttribute | string | null | null — a boolean data-attribute set alongside activeClass |
once | boolean | true — unlike useElementVisibility's own false; reveal effects almost always want this |
threshold | number | number[] | 0 |
rootMargin | string | '0px' |
root | Element | null | null |
stagger | RevealStaggerOptions | false | {} — pass false to disable |
watchMutations | boolean | true |
watchRoot | Element | document.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 |
pool | ObserverPool | shared default pool |
RevealStaggerOptions
Extends StaggerDelayOptions (step, max, unit, mode) with two extra fields:
| Field | Type | Default |
|---|---|---|
cssVar | string | null | '--reveal-delay' — pass null to skip writing the CSS var entirely |
applyInlineDelay | boolean | true |
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.
| Attribute | Overrides |
|---|---|
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 |
<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:
import { createRevealController } from '@macrulez/inview-core'
const controller = createRevealController({
stagger: { step: 60, max: 4 },
})<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:
function useRevealController(options?: RevealControllerOptions): RevealController<script setup lang="ts">
import { useRevealController } from '@macrulez/inview-vue'
useRevealController({ stagger: { step: 70, max: 4 } })
</script>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).