Skip to content
Limited timeStarter is now $9 a month instead of $20.See plans
charmotion

Scroll-Driven Logo and Mascot Animation: CSS, Rive and Video

By the Charmotion team · Updated

Cover illustration for the guide: Scroll-Driven Logo and Mascot Animation: CSS, Rive and Video, with Charmotion demo characters

For a logo that moves with scroll, use CSS scroll-driven animations: animation-timeline with scroll() or view(), animating transform and opacity. As of November 2026 they work in current Chrome, Edge, Firefox and Safari, so wrap them in @supports and let older browsers show the resting logo. For a Rive mascot, map scroll progress to a data-bound number.

Key takeaways

  • CSS scroll-driven animations became Baseline (newly available) in October 2026: Chrome and Edge 115, Firefox 159 and Safari 26 support animation-timeline.
  • Use view() to animate an element as it crosses the viewport and scroll() to tie motion to how far the page has scrolled. Declare animation-timeline after the animation shorthand.
  • For play-once reveals, an Intersection Observer that starts a video or fires a Rive trigger is simpler and works in older browsers too.
  • Rive deprecated scrub() in runtime v2.41.0. Drive a 1D blend state from a data-bound number instead.
  • Put every scroll animation behind prefers-reduced-motion: no-preference, and make the static state the finished, resting logo.

Already have a character or logo? See examples in real app screens: splash, onboarding, loading and empty-state screens, with the animation that plays in each.

Which scroll animation technique fits a logo or mascot?

There are two different effects people call "scroll animation," and they need different tools:

  • Scroll-linked (scrubbed): the animation's progress follows the scroll position. Scroll back up and it plays backwards. A header logo that shrinks as you scroll is scroll-linked.
  • Scroll-triggered: the animation plays on its own clock once something scrolls into view. A mascot that waves when its section appears is scroll-triggered.
TechniqueTypeBest forCost
CSS animation-timeline: view() or scroll()Scroll-linkedSVG or image logos: reveal, shrink, rotate, fadeNo JavaScript; transform and opacity only for smoothness
Intersection Observer + video or Rive triggerScroll-triggeredA mascot that reacts once, a logo intro when a section appearsA few lines of JavaScript; works everywhere
Rive data-bound number driving a blend stateScroll-linkedCharacter poses that follow scroll: looking down, turning, a progress mascotRive runtime plus a scroll handler
Video currentTime scrubbingScroll-linkedPre-rendered 3D logo turns and product spinsLarge files; seeking cost varies by encoding and device

For most logos, CSS is enough. Reach for Rive when the thing that moves has a face and needs real poses, and for video when the motion is a rendered 3D shot that no runtime can draw live.

How do CSS scroll-driven animations work?

MDN describes them as a way to "animate property values along a scroll-based timeline rather than the default time-based document timeline." You write normal @keyframes, then attach them to a timeline with animation-timeline:

  • scroll() creates a scroll progress timeline for "the root element, nearest scroller, or self." 0% is the top, 100% is the end of the scroll range.
  • view() creates a view progress timeline that "progresses based on changes in the visibility of a subject element inside its nearest scroll container." By default it runs from the moment the element first appears at one edge to the moment it leaves at the other.
  • animation-range narrows the part of the timeline the animation uses, for example entry 0% entry 100% to finish exactly when the element has fully entered.

A logo that rises and settles as its section scrolls in:

@keyframes logo-in {
  from { opacity: 0; transform: translateY(32px) scale(0.85) rotate(-6deg); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .section-logo {
      animation: logo-in 1ms linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 40%;
    }
  }
}

A header logo that shrinks over the first 200 pixels of page scroll:

@keyframes logo-shrink {
  to { transform: scale(0.7); }
}

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .site-logo {
      transform-origin: left center;
      animation: logo-shrink 1ms linear both;
      animation-timeline: scroll(root);
      animation-range: 0px 200px;
    }
  }
}

One trap catches almost everyone: animation-timeline is reset by the animation shorthand. MDN says you "need to declare animation-timeline after declaring any animation shorthand for it to take effect," and Chrome's guide gives the same warning. The 1ms duration is deliberate: on a scroll timeline the animation is stretched over the scroll range anyway, but MDN notes that Firefox needs a duration set for the animation to apply and advises 1ms. Use linear easing on the timeline so the logo tracks the scrollbar evenly; put character into the keyframes instead.

Which browsers support animation-timeline as of November 2026?

All current major engines. As of November 2026, MDN marks animation-timeline, view() and animation-range as "Baseline 2026, newly available," working across the latest browsers "since October 2026."

BrowserFirst version with animation-timeline
Chrome and Edge115
Firefox159
Safari (macOS and iOS)26
Samsung Internet23

Can I use puts global support at about 89% of users in November 2026. "Newly available" means older devices still miss it: anyone on Safari 18 or an older Firefox. That is why every sample above sits inside @supports.

The fallback should be the finished state, not a hidden logo. Write the element's normal styles as the resting logo, and add motion only inside the @supports block. Browsers without support then show a static, correct logo, which is also what reduced-motion users get. If the motion matters for older browsers too, use the Intersection Observer approach below as a play-once fallback; do not ship a scroll-event polyfill just for a logo.

How do you play a logo or mascot animation when it scrolls into view?

Use an Intersection Observer. MDN lists "deciding whether or not to perform tasks or animation processes based on whether or not the user will see the result" as one of its main uses, and it is Baseline widely available (since March 2019). This version plays muted videos while at least half visible and pauses them otherwise:

<video data-play-on-view muted playsinline loop preload="none"
       width="720" height="720" poster="/mascot-wave.webp">
  <source src="/mascot-wave.webm" type="video/webm">
  <source src="/mascot-wave.mp4" type="video/mp4">
</video>
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const video = entry.target;
    if (entry.isIntersecting && !reduce.matches) {
      video.play().catch(() => {});
    } else {
      video.pause();
    }
  }
}, { threshold: 0.5 });

document.querySelectorAll("video[data-play-on-view]").forEach((v) => observer.observe(v));

The muted attribute matters: MDN's autoplay guide says autoplay blocking is not applied when "the audio track is muted," so play() without a user gesture only works on silent video. The .catch() keeps a blocked play from logging an error.

For a one-shot reaction, such as a logo intro or a mascot wave, call observer.unobserve(entry.target) after the first play so it does not restart every time the visitor scrolls past. For a Rive file, the same observer can fire a trigger instead of calling play() (see the next section), and pause the Rive instance with r.pause() when it leaves the viewport.

How do you scrub a Rive animation with scroll?

Through the state machine, not the timeline. Rive's web runtime still has scrub(), but its docs mark it "Deprecated in v2.41.0, use a state machine to control playback instead." The supported way is a 1D blend state: Rive's docs say it mixes timelines "with a single numerical view model property," and list "scrolling interactions" among its uses. In the editor, key the start pose in one timeline and the end pose in another, add a 1D blend state driven by a number property (say scrollProgress, range 0 to 100), and export.

At runtime, set that number from scroll progress with data binding:

import { Rive } from "@rive-app/canvas";

const section = document.querySelector("#story");
const canvas = document.querySelector("#mascot");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const r = new Rive({
  src: "/mascot.riv",
  canvas,
  stateMachine: "State Machine 1",
  autoplay: true,
  autoBind: true,
  onLoad: () => {
    r.resizeDrawingSurfaceToCanvas();
    const progress = r.viewModelInstance?.number("scrollProgress");
    if (!progress || reduce) return;

    let queued = false;
    const update = () => {
      queued = false;
      const box = section.getBoundingClientRect();
      const range = box.height - window.innerHeight;
      const p = range > 0 ? Math.min(Math.max(-box.top / range, 0), 1) : 0;
      progress.value = Math.round(p * 100);
    };
    window.addEventListener("scroll", () => {
      if (!queued) {
        queued = true;
        requestAnimationFrame(update);
      }
    }, { passive: true });
    update();
  },
});

Why it is written this way: the scroll listener is passive and does its work at most once per frame in requestAnimationFrame, so scrolling never waits for it. Rounding to whole steps means the state machine only advances when the value changes, and Rive notes that a state machine that has nothing to do "settles" and stops advancing. Older files that use state machine inputs instead of a view model still work through r.stateMachineInputs(), but Rive now calls setting inputs directly deprecated and recommends data binding.

Not every scroll moment needs scrubbing. Often a mood change is enough: the mascot looks curious at the top of the page and happy at the pricing table. A Charmotion character set's Rive file already exposes a mood number that switches its looping moods and one trigger per one-shot reaction, such as success, through Data Binding. Setting mood from an Intersection Observer gives a scroll-aware mascot without authoring a blend state. The README in each set lists the values. See how it works.

Can you scrub a video with scroll?

Yes. Setting currentTime seeks: MDN says "changing the value of currentTime seeks the media to the new time," and the browser may adjust the position to one the media supports. Map scroll progress to time inside requestAnimationFrame:

const video = document.querySelector("#logo-turn");
const track = document.querySelector("#logo-turn-track");
let queued = false;

function update() {
  queued = false;
  if (!video.duration) return;
  const box = track.getBoundingClientRect();
  const range = box.height - window.innerHeight;
  const p = range > 0 ? Math.min(Math.max(-box.top / range, 0), 1) : 0;
  video.currentTime = p * video.duration;
}

window.addEventListener("scroll", () => {
  if (!queued) {
    queued = true;
    requestAnimationFrame(update);
  }
}, { passive: true });

It works best for short, pre-rendered shots, like a 3D logo turning 90 degrees, and it has real costs:

  • Seeking is decode work. How smooth a seek is depends on how the video was encoded and on the device. Encoding with more frequent keyframes usually helps, at the cost of a bigger file. Test on a mid-range phone.
  • The whole file has to be available. Do not combine scrubbing with preload="none"; preload it once the section is near.
  • Keep it short and small. A few seconds at the size it is shown. For a logo that only needs to rotate or scale, CSS transforms on an SVG or image do the same job with no video at all.
  • Give it a poster showing the resting frame, which is also the reduced-motion and no-JavaScript state.

How do you keep scroll animation smooth and comfortable?

Smooth first. Chrome's guide explains why scroll events are a poor base for animation: browsers scroll on a separate process and deliver scroll events asynchronously, and "main thread animations are subject to jank." CSS scroll-driven animations avoid that and can run off the main thread. Keep them cheap:

  • Animate transform and opacity. Changing top, left, width or height on scroll causes layout work and, per web.dev, layout shifts.
  • One scrubbed element per screen. A logo, a mascot or a product shot, not all three.
  • Passive listeners and one update per frame for anything driven from JavaScript, as in the samples above.
  • Pause what is not visible. A Rive canvas or video that scrolled away should stop drawing.

Then comfort. Scroll-linked motion is the kind most likely to bother people with vestibular disorders. W3C's explanation of SC 2.3.3 (Animation from Interactions, Level AAA) names parallax scrolling as an often non-essential animation and says motion triggered by scrolling "can trigger vestibular disorders," with reactions including "distraction, dizziness, headaches and nausea." Practical rules:

  • Put every scroll animation inside @media (prefers-reduced-motion: no-preference) or check matchMedia in JavaScript, as all the samples here do.
  • Keep movement small: a few dozen pixels, a few degrees. Large zooms and full-screen parallax are where discomfort starts.
  • Never hide content behind a scroll animation. If the animation fails or is turned off, the logo and text must still be there.

More on the reduced-motion setting is in accessible animation and reduced motion.

How to add a scroll-driven logo animation with a fallback

  1. Style the resting logo. Write the logo's normal CSS as its finished, static state. That is what older browsers and reduced-motion users will see.
  2. Write the keyframes. Animate only transform and opacity, from the start pose to the resting pose.
  3. Attach a timeline. Inside @supports (animation-timeline: view()), set the animation shorthand, then animation-timeline: view() or scroll(), then animation-range.
  4. Guard for motion settings. Wrap the block in @media (prefers-reduced-motion: no-preference).
  5. Add a play-once fallback if needed. For older browsers, start a one-shot animation with an Intersection Observer instead of a scroll listener.
  6. Test. Scroll slowly and quickly on a mid-range phone, scroll back up, and check the static state with reduced motion on.

Frequently asked questions

Why is my animation-timeline not doing anything?

The most common cause is order: the animation shorthand resets animation-timeline, so declare animation-timeline after it. Also check that the element has a scroll container on the right axis (view() is inactive if that axis does not scroll) and that the browser supports it: Chrome 115, Firefox 159 and Safari 26 or later.

Do I still need a scroll-driven animation polyfill in November 2026?

Usually not for a logo. CSS scroll-driven animations are supported in current Chrome, Edge, Firefox and Safari as of November 2026. For older browsers, show the resting logo or play a one-shot animation with an Intersection Observer instead of loading a polyfill.

Can a Rive animation follow the scroll position?

Yes. Build a 1D blend state in Rive driven by a number view model property, then set that number from scroll progress in a passive scroll listener, at most once per frame. Rive's scrub() method still exists but is deprecated since runtime v2.41.0.

Sources

Related guides