---
title: "Listeners"
description: "AosListener, ParallaxListener and ScrollClassesListener, the scroll engines behind reveals, parallax and scroll-state classes."
requested_language: ar
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/ar/components/listeners
version: unreleased (main)
docs_index: https://docs.systhema.app/ar/llms.txt
---
> This page isn't translated yet. Showing English.


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`](https://docs.systhema.app/ar/components/provider.md) mounts all three, so you only import them when you don't use the provider. Each renders nothing.

```tsx preview iframe height=420 title="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 }} />
    </>
  )
}
```

## Import

```tsx
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:

```tsx title="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>
  )
}
```

## `AosListener`

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](https://docs.systhema.app/ar/styling/animation.md).

| Class                  | Effect                                                                             |
| ---------------------- | ---------------------------------------------------------------------------------- |
| `aos`                  | Takes part in scroll reveals (`animates-on-scroll` is the long alias)              |
| `animates-once`        | Reveals once and never re-arms                                                     |
| `animate-disable`      | Opts the element out: it renders in its end state                                  |
| `aos-disable-children` | Opts every descendant out                                                          |
| `aos-allow-children`   | Opts 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](https://docs.systhema.app/ar/styling/animation.md#child-reveals).

### Trigger points

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.

```tsx preview iframe height=360 title="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 works

The listener registers every animated element with two `IntersectionObserver`s, 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.

<!-- generated:props @systhemaui/react AosListenerProps -->

| Prop       | Type                       | Default | Description |
| ---------- | -------------------------- | ------- | ----------- |
| `resetKey` | `string \| number \| null` | -       |             |

<!-- /generated -->

### `startAosListener()`

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

```ts
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.

## `ParallaxListener`

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`](https://docs.systhema.app/ar/components/image.md#parallax) and [`Video`](https://docs.systhema.app/ar/components/video.md) `parallax` prop adds for you.

```tsx preview iframe height=420 title="Parallax image"
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 }} />
    </>
  )
}
```

| Class               | Effect                                                                                                   |
| ------------------- | -------------------------------------------------------------------------------------------------------- |
| `parallax`          | Moves the element; on the element or set by the media prop                                               |
| `parallax--big`     | Uses `translateRangeBig`; clamped like the default range, so with one shared `scale` it moves no further |
| `parallax--noscale` | Moves 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:

```ts title="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](https://docs.systhema.app/ar/styling/parallax.md#configuration).

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.

<!-- generated:props @systhemaui/react ParallaxListenerProps -->

| Prop       | Type                       | Default | Description |
| ---------- | -------------------------- | ------- | ----------- |
| `resetKey` | `string \| number \| null` | -       |             |

<!-- /generated -->

### `startParallaxListener()`

```ts
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.

## `ScrollClassesListener`

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

| Class               | On `<body>` while                                                       |
| ------------------- | ----------------------------------------------------------------------- |
| `scroll-on-top`     | the page is scrolled less than 1 px from the top                        |
| `is-scrolling-down` | the 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:

```tsx preview iframe height=360 title="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:

```css
.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);
}
```

<!-- generated:props @systhemaui/react ScrollClassesListenerProps -->

| Prop       | Type                       | Default | Description |
| ---------- | -------------------------- | ------- | ----------- |
| `resetKey` | `string \| number \| null` | -       |             |

<!-- /generated -->

### `startScrollClassesListener()`

```ts
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 navigation

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:

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

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

## Next.js

`@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`](https://docs.systhema.app/ar/nextjs/provider.md#why-the-listeners-need-nextnavigation).

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

## HTML and CSS

Without React, the [vanilla JS bundle](https://docs.systhema.app/ar/styling/vanilla-js.md) runs the same behaviour: `initAos()`, `initParallax()` and `initScrollClasses()` start on load. The markup is the classes from the tables above:

```html
<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>
```

## Accessibility

- 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](https://docs.systhema.app/ar/concepts/motion.md#reduced-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.

## Related

- [SysthemaProvider](https://docs.systhema.app/ar/components/provider.md)
- [Animation](https://docs.systhema.app/ar/styling/animation.md) and [Animation timing](https://docs.systhema.app/ar/styling/animation-timing.md)
- [Parallax](https://docs.systhema.app/ar/styling/parallax.md)
- [Motion](https://docs.systhema.app/ar/concepts/motion.md)
