Docs
Next

Motion

Scroll reveals, stagger, parallax, motion tokens and how reduced motion is handled.

On this page

Systhema's motion is built from CSS classes plus three small listeners. Content fades in as it scrolls into view, siblings can cascade, media can move with parallax, and interactive elements ease between states. All of it respects prefers-reduced-motion. This page explains how the pieces fit; the class lists are on the linked styling pages.

Scroll revealsLink to this section

A reveal is two classes: an animation and a trigger.

  • The animation is one of animate-fadein, animate-fadeinup, animate-fadeindown, animate-fadeinleft, animate-fadeinright, animate-fadeinzoomout or animate-fadeout. Each runs once for 1s on an ease-out-cubic curve and keeps its end state.
  • The trigger is aos (or its long form animates-on-scroll). It keeps the element hidden, with no animation, until AosListener adds the animated class as the element's top edge comes 20px into the viewport.

Scroll this preview into view, or press Replay:

The six reveal animations
import { useState } from 'react'
import { Button } from '@systhemaui/next'

const animations = [
  'animate-fadein',
  'animate-fadeinup',
  'animate-fadeindown',
  'animate-fadeinleft',
  'animate-fadeinright',
  'animate-fadeinzoomout',
]

export default function Demo() {
  const [run, setRun] = useState(0)
  return (
    <div className="flex w-full flex-col items-center gap-8 overflow-hidden">
      <div key={run} className="grid w-full grid-cols-2 gap-4 md:grid-cols-3">
        {animations.map((animation) => (
          <div
            key={animation}
            className={`aos ${animation} bg-layout-alternative color-body text-small rounded-lg p-6 text-center`}
          >
            {animation}
          </div>
        ))}
      </div>
      <Button.button variant="secondary" onClick={() => setRun(run + 1)}>
        Replay
      </Button.button>
    </div>
  )
}

An element replays its reveal when it scrolls back below the fold and in again. Add animates-once to reveal it only the first time. Without JavaScript, or before the listener runs, aos elements stay hidden, which is why the listener ships in SysthemaProvider and in the vanilla JS bundle for HTML projects.

Which components revealLink to this section

Most React components reveal on their own: Heading, Paragraph, Card, Columns, Stack, Quote, the media components and others add the classes in packages.react.animationClasses ('aos animate-fadeinup' in the templates). Pass disableAnimation to one component to turn it off, or leave animationClasses unset to turn reveals off project-wide. See Configuration.

Containers reveal as oneLink to this section

A card, stack, hero, column, row, footer, accordion, quote, figure or media overlay that animates reveals as one composed unit: it cancels every reveal inside it, at any depth. So a Card fades in once with its heading and text, rather than in three steps. To let a Stack's children reveal on their own, pass allowChildAnimation (the aos-allow-children class); for other containers, turn the container's own animation off. aos-disable-children cancels every reveal inside any element, and animate-disable cancels one. See Animation.

StaggerLink to this section

stagger-<n> delays siblings so a row cascades: put it on each sibling, with n the number of items per row (1 to 10). The first item of each group of n starts at once and each next one waits one more step. A step is --tw-stagger-delay (300ms by default); stagger-delay-<ms> sets it, using the transition-duration scale.

Three cards with stagger-3
import { useState } from 'react'
import { Button, Card, Heading, Paragraph } from '@systhemaui/next'

const steps = ['Export', 'Sync', 'Build']

export default function Demo() {
  const [run, setRun] = useState(0)
  return (
    <div className="flex w-full flex-col items-center gap-8">
      <div key={run} className="stagger-delay-200 grid w-full gap-4 md:grid-cols-3">
        {steps.map((step) => (
          <Card key={step} className="stagger-3">
            <Heading.h4>{step}</Heading.h4>
            <Paragraph>Each card waits one step longer than the one before it.</Paragraph>
          </Card>
        ))}
      </div>
      <Button.button variant="secondary" onClick={() => setRun(run + 1)}>
        Replay
      </Button.button>
    </div>
  )
}

stagger-delay-200 sits on the parent because --tw-stagger-delay is inherited. PostsList staggers its grid columns with the same variable. The animate-delay-*, animate-duration-* and animate-ease-* utilities tune a single element; see Animation timing.

ParallaxLink to this section

Media with the parallax class drifts against the scroll inside its overflow: hidden wrapper. ParallaxListener writes the transforms, the Image and Video components add the class with their parallax prop, and the parallax key of systhema.config sets the range and scale. See Parallax.

Transitions and easingLink to this section

Hover and focus changes use CSS transitions. packages.react.transitionClasses ('duration-300 ease-out-cubic' in the templates) is added to interactive components such as Button, Link, Chip, linked Cards and form controls. The easing curves exist as utilities (ease-out-cubic, ease-in-out-quint, …) and as --ease-* variables for your own CSS; see Easing and transitions.

A designer can take over motion through two optional token collections: easings overrides the built-in curves one by one, and transition sets per-component durations and curves. Without them every component keeps its built-in behaviour. See Motion tokens.

Reduced motionLink to this section

When the visitor asks for reduced motion (prefers-reduced-motion: reduce):

  • Every reveal shows its end state at once: fully visible, no transform. It never stays at opacity: 0, with or without JavaScript.
  • The parallax engine writes no transform, and the media rests in its wrapper unmoved.
  • Both listeners follow the preference live, so changing it mid-visit needs no reload.

Your own animations should do the same. Snap to the end state, not to hidden:

@layer app {
  .marquee {
    animation: scroll 20s linear infinite;
  }

  @media (prefers-reduced-motion: reduce) {
    .marquee {
      animation: none;
    }
  }
}