Docs
Next

Listeners

AosListener, ParallaxListener and ScrollClassesListener, the scroll engines behind reveals, parallax and scroll-state classes.

On this page

Three client components drive Systhema's scroll behaviour: AosListener reveals elements as they enter the viewport, ParallaxListener moves .parallax media against the scroll, and ScrollClassesListener keeps scroll-state classes on <body>. SysthemaProvider mounts all three, so you only import them when you don't use the provider. Each renders nothing.

Scroll inside the frame
import { Card, Heading, Paragraph } from '@systhemaui/next'

const reveals = [
  { animation: 'animate-fadeinup', label: 'Fade in up' },
  { animation: 'animate-fadeinleft', label: 'Fade in from the left' },
  { animation: 'animate-fadeinright', label: 'Fade in from the right' },
  { animation: 'animate-fadeinzoomout', label: 'Fade in and zoom out' },
]

export default function Demo() {
  return (
    <>
      {/* Demo frame only: a fixed-height page, so the frame itself scrolls. */}
      <style>{'body { height: 420px; justify-content: flex-start; }'}</style>
      <Paragraph type="lead" style={{ marginBlock: 160 }}>
        Scroll down: each card reveals once its top edge passes the bottom of the viewport.
      </Paragraph>
      {reveals.map(({ animation, label }) => (
        <Card
          key={animation}
          disableAnimation
          className={`aos ${animation}`}
          style={{ width: '100%', maxWidth: 480, flexShrink: 0 }}
        >
          <Heading.h4>{label}</Heading.h4>
          <Paragraph>
            <code>aos {animation}</code>
          </Paragraph>
        </Card>
      ))}
      <div style={{ height: 160, flexShrink: 0 }} />
    </>
  )
}

ImportLink to this section

import { AosListener, ParallaxListener, ScrollClassesListener } from '@systhemaui/next'

In a React app without Next.js, import them from @systhemaui/react. Mount each once, in the root layout, next to the content it watches:

app/layout.tsx
import type { ReactNode } from 'react'
import { AosListener, ParallaxListener, ScrollClassesListener } from '@systhemaui/next'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AosListener />
        <ParallaxListener />
        <ScrollClassesListener />
        {children}
      </body>
    </html>
  )
}

AosListenerLink to this section

Adds the animated class to .aos and .animates-on-scroll elements when they enter the viewport. The entrance itself is CSS: an element with aos and an animate-* class stays hidden until animated lands, then plays its animation. Components add aos plus your configured animationClasses for you; on your own markup, write the classes yourself. The classes are listed on Animation.

ClassEffect
aosTakes part in scroll reveals (animates-on-scroll is the long alias)
animates-onceReveals once and never re-arms
animate-disableOpts the element out: it renders in its end state
aos-disable-childrenOpts every descendant out
aos-allow-childrenOpts the children of a .stack back in (written by <Stack allowChildAnimation>)

Container components (Stack, Card, Hero, Columns items and others) suppress the reveals of their children, so the container animates as one unit. aos-allow-children lifts that for a Stack; it has no effect outside a .stack and does not override aos-disable-children or animate-disable. See Child reveals.

Trigger pointsLink to this section

An element reveals once its top edge is 20 px inside the bottom of the viewport. Unless it carries animates-once, it re-arms once it has scrolled back more than 10% of the viewport height below the fold, so it plays again the next time it enters. Reveal and re-arm sit on different lines on purpose: the gap absorbs scroll jitter and the entrance animation's own movement (fadeInUp starts 5vh lower), which would otherwise replay the animation.

Reveal once or every time
import { Card, Heading, Paragraph } from '@systhemaui/next'

export default function Demo() {
  return (
    <>
      {/* Demo frame only: a fixed-height page, so the frame itself scrolls. */}
      <style>{'body { height: 360px; justify-content: flex-start; }'}</style>
      <Paragraph type="lead" style={{ marginBlock: 100 }}>
        Scroll down, then back up past both cards, then down again.
      </Paragraph>
      <Card
        disableAnimation
        className="aos animate-fadeinup"
        style={{ width: '100%', maxWidth: 480, flexShrink: 0 }}
      >
        <Heading.h4>Replays</Heading.h4>
        <Paragraph>
          <code>aos animate-fadeinup</code>
        </Paragraph>
      </Card>
      <Card
        disableAnimation
        variant="highlighted"
        className="aos animate-fadeinup animates-once"
        style={{ width: '100%', maxWidth: 480, flexShrink: 0 }}
      >
        <Heading.h4>Plays once</Heading.h4>
        <Paragraph>
          <code>aos animate-fadeinup animates-once</code>
        </Paragraph>
      </Card>
      <div style={{ height: 240, flexShrink: 0 }} />
    </>
  )
}

How it worksLink to this section

The listener registers every animated element with two IntersectionObservers, one that reveals and one, with a wider root, that re-arms. There are no scroll, resize or touchmove handlers, so scrolling costs nothing on the main thread.

An observer only reports threshold crossings, so an element that jumps from below the viewport to above it in one step (a restored scroll position, a deep link, an in-page anchor) would never get an entry. A batched measure-then-reveal sweep runs on load, pageshow, hashchange and scrollend to catch those, and it also reveals anything in the last few pixels of a fully scrolled page. Elements added after the first render (a load-more listing, a client-side widget) are picked up by a MutationObserver. Browsers without IntersectionObserver fall back to a measured, frame-coalesced implementation with the same trigger points.

Under prefers-reduced-motion: reduce the animate plugin shows every .aos element in its end state at once, whether or not animated has been written, so the content is visible even if JavaScript never runs.

PropTypeDefaultDescription
resetKeystring | number | null-

startAosListener()Link to this section

The engine on its own, for code outside React. Both packages' AosListener call it from an effect.

import { startAosListener } from '@systhemaui/react'

const stop = startAosListener()
// later, for example before swapping the page content:
stop()

It is a no-op outside the browser, and the teardown detaches every observer and listener, so it is safe to restart.

ParallaxListenerLink to this section

Writes a translateY(…) scale(…) transform on every .parallax element as the page scrolls, moving it from the start of the travel range when it enters the viewport to the end when it leaves. The element sits in an overflow: hidden wrapper (.parallax-wrapper), which the Image and Video parallax prop adds for you.

import { Image, Paragraph } from '@systhemaui/next'

export default function Demo() {
  return (
    <>
      {/* Demo frame only: a fixed-height page, so the frame itself scrolls. */}
      <style>{'body { height: 420px; justify-content: flex-start; }'}</style>
      <Paragraph type="lead" style={{ marginBlock: 120 }}>
        Scroll down: the image drifts inside its frame.
      </Paragraph>
      <div style={{ width: '100%', maxWidth: 640, flexShrink: 0 }}>
        <Image
          src="/demo-assets/mountains.webp"
          alt="Pastel mountain ridges under a pale sky"
          width={1920}
          height={1080}
          aspectRatio="21/9"
          parallax
        />
      </div>
      <div style={{ height: 420, flexShrink: 0 }} />
    </>
  )
}
ClassEffect
parallaxMoves the element; on the element or set by the media prop
parallax--bigUses translateRangeBig; clamped like the default range, so with one shared scale it moves no further
parallax--noscaleMoves without scaling; the travel stays the same, so the wrapper edges can show

The modifiers work on the element or on its parent. The ranges come from the parallax config:

systhema.config.ts
import type { SysthemaConfig } from '@systhemaui/core'

const config: SysthemaConfig = {
  parallax: {
    translateRange: { from: -16, to: 16 },
    translateRangeBig: { from: -32, to: 32 },
    scale: 1.1666,
  },
}

export default config

Both ranges are clamped to what scale can cover (50 × (scale − 1) percent), so with the shipped scale of 1.1666 the media travels about 8.3% either way, and parallax--big moves exactly as far as the default: the big range only differs once you raise scale. See Parallax.

Under prefers-reduced-motion: reduce the engine writes no transform and clears any it wrote before, and the stylesheet's reduced-motion rule lets the media fill its wrapper. It follows the preference live.

PropTypeDefaultDescription
resetKeystring | number | null-

startParallaxListener()Link to this section

import { startParallaxListener } from '@systhemaui/react'

const stop = startParallaxListener()

It reads the parallax config once when it starts, coalesces scroll, resize, touchmove and load into one update per frame, and returns a teardown that removes every listener.

ScrollClassesListenerLink to this section

Keeps two classes on <body>, for CSS that reacts to the scroll position:

ClassOn <body> while
scroll-on-topthe page is scrolled less than 1 px from the top
is-scrolling-downthe reader has moved 150 px further down than the last direction change

The 150 px threshold keeps a trackpad's jitter or a rubber-band bounce from flipping the class back and forth. The canonical use is a header that slides away while the reader scrolls down and returns when they scroll up:

Hide on scroll down
import { Heading, Paragraph } from '@systhemaui/next'

const css = `
  body { height: 360px; justify-content: flex-start; padding-top: 96px; }
  .demo-bar {
    position: fixed; inset: 0 0 auto 0; padding: 16px 24px;
    background: var(--color-layout-bg-alternative);
    border-bottom: 1px solid var(--color-separator-line);
    transition: transform 300ms ease-out, box-shadow 300ms ease-out;
  }
  body:not(.scroll-on-top) .demo-bar { box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }
  body.is-scrolling-down .demo-bar { transform: translateY(-100%); }
`

export default function Demo() {
  return (
    <>
      {/* Demo frame only: a fixed-height page, so the frame itself scrolls. */}
      <style>{css}</style>
      <div className="demo-bar">
        <Heading.h5>Scroll down, then up</Heading.h5>
      </div>
      {Array.from({ length: 14 }, (_, index) => (
        <Paragraph key={index} style={{ maxWidth: 560, flexShrink: 0 }}>
          The bar gets a shadow as soon as the page leaves the top, slides away after 150 px of
          scrolling down, and comes back after 150 px of scrolling up.
        </Paragraph>
      ))}
    </>
  )
}

The same rules in a stylesheet:

.header {
  transition: transform 300ms ease-out;
}

body.is-scrolling-down .header {
  transform: translateY(-100%);
}

body:not(.scroll-on-top) .header {
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}
PropTypeDefaultDescription
resetKeystring | number | null-

startScrollClassesListener()Link to this section

import { startScrollClassesListener } from '@systhemaui/react'

const stop = startScrollClassesListener()

It sets the classes once on start, so the first paint reflects the current position, then updates at most once per frame.

Restarting after navigationLink to this section

In @systhemaui/react, every listener takes a resetKey. Changing it tears the engine down and starts it again, which re-registers new elements and re-measures. Pass the current route from your router:

import { AosListener, ParallaxListener } from '@systhemaui/react'

function Listeners({ pathname }: { pathname: string }) {
  return (
    <>
      <AosListener resetKey={pathname} />
      <ParallaxListener resetKey={pathname} />
    </>
  )
}

Next.jsLink to this section

@systhemaui/next ships its own AosListener, ParallaxListener and ScrollClassesListener. They run the same engines (the start* functions, which @systhemaui/next re-exports) and differ in one thing: they restart on every route change by reading usePathname() from next/navigation, so they take no props. A client-side navigation patches the DOM without a load or DOMContentLoaded event, so a listener that waited for those would miss every page after the first. See Why the listeners need next/navigation.

The three props types (AosListenerProps, ParallaxListenerProps, ScrollClassesListenerProps) are exported from @systhemaui/react only.

HTML and CSSLink to this section

Without React, the vanilla JS bundle runs the same behaviour: initAos(), initParallax() and initScrollClasses() start on load. The markup is the classes from the tables above:

<div class="aos animate-fadeinup">Reveals on scroll</div>

<figure class="media-wrapper parallax-wrapper aspect-21/9">
  <img class="media parallax" src="/mountains.webp" alt="Pastel mountain ridges" />
</figure>

AccessibilityLink to this section

  • Both motion engines honour prefers-reduced-motion and follow changes to it live. Reveals snap to their visible end state, never to opacity: 0, and parallax writes no transform. See Motion.
  • Content behind a reveal is in the DOM and the accessibility tree from the start; only its opacity and transform change.
  • A header that hides on scroll should come back when it receives focus, so keyboard users can reach it. Add a :focus-within rule next to the is-scrolling-down one.