How NRK uses scroll-driven animations to bring stories to life

Published: February 26, 2026

Scroll-driven animations have evolved from janky, main-thread JavaScript implementations to smooth, accessible, off-main-thread experiences using modern CSS and UI features like Scroll Timelines and View Timelines. This shift enables quick prototyping and high-performance animations, while enabling teams to create polished, scrollytelling pages as demonstrated in this article.

NRK and storytelling

NRK (Norwegian Broadcasting Corporation) is the public service broadcaster in Norway. The team behind the implementation described in this article is called Visuelle Historier in Norwegian, translating roughly to Visual Stories in English. The team works with design, graphics, and development for editorial projects for TV, radio, and the web, developing visual identities, content graphics, feature articles and new visual storytelling formats. The team also works with NRK's design profile and sub-brands, creating tools and templates to make it easier to publish content in line with NRK's brand identity.

How NRK uses scroll-driven animations

Scroll-driven and scroll-triggered animations enhance their storytelling articles by making them more interactive, engaging, and memorable. This approach is especially useful in non-fiction narratives where few or no images are available.

These animations help strengthen or create dramaturgical points, propel the story forward, and develop small visual narratives that align with or reinforce the text. By being scroll-driven, these animations allow the user to control the progression of the narrative through their scrolling.

Elevating the user experience

NRK's user insights reveal that readers appreciate how these animations guide their focus. By highlighting text or animations as they scroll, users find it easier to identify key points and understand the most important aspects of the story, especially when skimming.

Additionally, animating graphics can simplify complex information, making it easier for users to comprehend relationships and changes over time. By building, adding, or highlighting information dynamically, NRK can present content in a more pedagogical and engaging way.

Setting a mood

Animations can be powerful tools for setting or enhancing the mood of a story. By adjusting the timing, speed, and style of animations, NRK can evoke emotions that match the narrative's tone.

Break up text and provide visual relief

NRK often uses small animated illustrations to break up long blocks of text in the form of a simple dinkus or a small illustration, giving readers a momentary pause from the narrative. Many users appreciate the variation, noting that it breaks up the text and makes it more digestible. They feel that it provides a welcome pause in the narrative.

Respecting accessibility needs and user preferences

NRK's public pages must be accessible to all citizens of Norway. Therefore, the pages must respect the user's preference for reduced motion. All page content must be available to users who have enabled this browser setting.

Designing scroll-driven animations

NRK has streamlined the design workflow by developing and integrating a new scroll animation tool directly into their Sanity Content Management System (CMS). Developed in collaboration between the teams that develop and maintain the site and the CMS solutions, this tool allows designers to easily prototype and implement scroll animations with visual cues for start and end positions of an animated element, and the ability to preview animations in real-time. This innovation gives designers greater control and accelerates the design process directly within the CMS.

Showing the area that has scrolled into view in a tool.
Similar example of visual cues for start and end positions of animated elements—not the real CMS tool.

Scroll-driven animations in the browser

Story-driven animation

The man who wasn't missed.

This article about a man who remained dead in his apartment for nine years had to rely heavily on illustrations due to the lack of other visual elements. The illustrations were animated through scrolling to underscore the narrative, such as in the animation wherein the night falls, lights in a multi-storey building turn on progressively until only one apartment remains unlit. The animation was built using NRK's in-house scroll-driven animation tool.

Text fade animation

Permafrost.

This article begins with a brief introduction, mirroring the opening sequence of a film. Concise texts paired with full-screen visuals were designed to hint at the article's content, building anticipation to encourage readers to delve into the full piece. The title page was crafted to resemble a film poster, with scroll-driven animations employed to reinforce this sensation by smoothly animating the text up and out.

.article-section {
  animation: fade-up linear;
  animation-timeline: view();
  animation-range: entry 100% exit 100%;
}

Scroll-animated typography

Animated typography in the title of an article—Sick leave.

With the introduction in "Sjukt sjuke" (which roughly translates to "Sickly sick") NRK wanted to draw readers into an article about the increasing sick leave rates in Norway. The title was meant to be a visual eye-catcher that gives readers a hint that this is not the usual, boring, number-driven story they might expect. The NRK team wanted the text and illustrations to play on the themes of the piece, using typography and scroll-driven animations to enhance this. The article utilizes NRK News' new font and design profile.

<h1 aria-label="sjuke">
  <span>s</span><span>j</span><span>u</span><span>k</span><span>e</span>
<h1>
h1 span {
  display: inline-block;
}
if (window.matchMedia('print, (prefers-reduced-motion: reduce)').matches) {
  return;
}

const heading = document.querySelector("h1");
const letters = heading.querySelectorAll("span");

const timeline = new ViewTimeline({ subject: heading });
const scales = [/**/];
const rotations = [/**/];

for ([index, el] of letters.entries()) {
  el.animate(
    {
      scale: ["1", scales[index]],
      rotate: ["0deg", rotations[index]]
    },
    {
      timeline,
      fill: "both",
      rangeStart: "contain 30%",
      rangeEnd: "contain 70%",
      easing: "ease-out"
    }
  );
}

Highlighting scroll-snapped items

Kids in institutions.

Readers who have finished an article often want to read more about the same issue. In the articles about youth abusing substances in institutions, NRK wanted to recommend a single article as the next read, while also giving readers the option of several others if they so wished. The solution was a swipeable navigation implemented with scroll snap and scroll-driven animations. The animations ensured that the active element was in focus, while the remaining elements were dimmed down.

for (let item of items) {
  const timeline = new ViewTimeline({ subject: item, axis: "inline" });
  const animation = new Animation(effect, timeline);
  item.animate(
    {
      opacity: [0.3, 1, 0.3]
    },
    { timeline, easing: "ease-in-out", fill: "both" }
  );
  animation.rangeStart = "cover calc(50% - 100px)";
  animation.rangeEnd = "cover calc(50% + 100px)";
}

Scroll-animation triggering a regular animation

Budget.

In this article about Norway's national budget, NRK aimed to make an otherwise heavy and dull number-based story more accessible and personalized. The goal was to break down an enormous and incomprehensible budget figure and give the reader a personal reckoning of what their tax money is being spent on. Each sub-section focused on a specific item in the national budget. The reader's total tax contribution was symbolized with a blue bar that was divided up to reveal the reader's contribution to these individual items. The transition was achieved with a scroll-driven animation that triggered the individual items to be animated in.

const timeline = new ViewTimeline({
  subject: containerElement
});

// Setup scroll-driven animation
const scrollAnimation = containerElement.animate(
  {
    "--cover-color": ["blue", "lightblue"],
    scale: ["1 0.2", "1 3"]
  },
  {
    timeline,
    easing: "cubic-bezier(1, 0, 0, 0)",
    rangeStart: "cover 0%",
    rangeEnd: "cover 50%"
  }
);

// Wait for scroll-driven animation to complete
await scrollAnimation.finished;
scrollAnimation.cancel();

// Trigger time-driven animations
for (let [index, postElement] of postElements.entries()) {
  const animation = postElement?.animate(
    { scale: ["1 3", "1 1"] },
    {
      duration: 200,
      delay: index * 33,
      easing: "ease-out",
      fill: "backwards"
    }
  );
}

"We've done scroll-driven animation for a long time. Before the Web Animations API existed, we had to use scroll events, later combined with the Intersection Observer API. This was often a very time consuming task, and now this is made trivial by the Web Animations and Scroll-Driven Animations APIs"—Helge Silset, Front-end Developer at NRK

NRK has many different Web Components that can be plugged into one of their custom elements, called ScrollAnimationDriver (<scroll-animation-driver>), supporting the following animations:

  • Layers with [KeyframeEffects](https://developer.mozilla.org/docs/Web/API/KeyframeEffect)
  • Lottie animations
  • mp4
  • three.js
  • <canvas>

The following example uses layers with KeyframeEffects:

<scroll-animation-driver data-range-start='entry-crossing 50%' data-range-end='exit-crossing 50%'>
  <layered-animation-effect>
    <picture>
      <source />
      <img />
    </picture>

    <picture>
      <source />
      <img />
    </picture>

    <picture>
      <source />
      <img />
    </picture>
  </layered-animation-effect>
</scroll-animation-driver>

NRK's JavaScript implementation of their <scroll-animation-driver> custom element:

export default class ScrollAnimationDriver extends HTMLElement {
  #timeline

  connectedCallback() {
    this.#timeline = new ViewTimeline({subject: this})
    for (const child of this.children) {
      for (const effect of child.effects ?? []) {
        this.#setupAnimationEffect(effect)
      }
    }
  }

  #setupAnimationEffect(effect) {
    const animation = new Animation(effect, this.#timeline) 
    animation.rangeStart = this.rangeStart
    animation.rangeEnd = this.rangeEnd

    if (this.prefersReducedMotion) {
      animation.currentTime = CSS.percent(this.defaultProgress * 100)
    } else {
      animation.play()
    }
  }
}

export default class LayeredAnimationEffect extends HTMLElement {
  get effects() {
    return this.layers.flatMap(layer => toKeyframeEffects(layer))
  }
}

Scrolling performance

NRK had a very performant JavaScript implementation before scroll-driven animations were used, but now scroll-driven animations allow them to have even better performance without having to worry about scroll jank, even on low-powered devices.

  • Non-SDA task duration: 1 ms.
  • SDA task duration: 0.16 ms.
The Performance tab of Chrome DevTools.
The recording in the Performance tab of Chrome DevTools with a 6 times CPU slowdown shows 0.16ms for each task in a new frame.

To read more about the difference in scrolling performance between JavaScript implementations versus scroll-driven animations, the article A case study on scroll-driven animations performance goes into more detail.

Accessibility and UX considerations

Accessibility plays an important role in NRK's public pages as they have to be accessible to all of Norway's citizens under many circumstances. NRK makes sure scroll animations are accessible in a few different ways:

  • Respecting user's preferences for reduced motion: Using media query screen and (prefers-reduced-motion: no-preference) to apply the animation as a progressive enhancement. It's also helpful to handle print styles at the same time.
  • Considering the wide range of devices and varying scroll input precision: Some users may scroll in steps (Space or up/down keys, navigating to landmarks using a screen reader) and not see the entire animation. Make sure crucial information isn't missed.
  • Being cautious with animations that show or hide content: For users relying on Operating System (OS) zoom, it can be difficult to notice that hidden content will appear as they scroll. Avoid making users search for it. If hiding or showing content is necessary, ensure consistency in where it appears and disappears.
  • Avoiding large changes in brightness or contrast in the animation: Since scroll-driven animations depend on user control, abrupt luminance shifts can appear as flashing, which may trigger seizures for some users.
@media (prefers-reduced-motion: no-preference) {
  .article-image {
    opacity: 0;
    transition: opacity 1s ease-in-out;
  }
  .article-image.visible {
    opacity: 1;
  }
}

Browser support

For wider browser support of the ScrollTimeline and ViewTimeline, NRK uses an open-source polyfill, which has an active community contributing to it.

Currently, the polyfill is loaded conditionally when ScrollTimeline is not available and using a stripped down version of the polyfill without CSS support.

if (!('ScrollTimeline' in window)) {
  await import('scroll-timeline.js')
}

Browser support detection and handling in CSS:

@supports not (animation-timeline: view()) {
  .article-section {
    translate: 0 calc(-15vh * var(--fallback-progress));
    opacity: var(--fallback-progress);
  }
}

@supports (animation-timeline: view()) {
  .article-section {
    animation: --fade-up linear;
    animation-timeline: view();
    animation-range: entry 100% exit 100%;
  }
}

In the previous example for unsupported browsers, NRK is using a CSS variable, --fallback-progress, as a fallback to controlling the animation timeline for the translate and opacity properties.

The --fallback-progress CSS variable is then updated with a scroll event listener and requestAnimationFrame in JavaScript like so:

function updateProgress() {
  const end = el.offsetTop + el.offsetHeight;
  const start = end - window.innerHeight;
  const scrollTop = document.scrollingElement.scrollTop;
  const progress = (scrollTop - start) / (end - start);
  document.body.style.setProperty('--fallback-progress', clamp(progress, 0, 1));
}


if (!CSS.supports("animation-timeline: view()")) {
  document.addEventListener('scroll', () => {
    if (!visible || updating) {
      return;
    }

    window.requestAnimationFrame(() => {
      updateProgress();
      updating = false;
    });

    updating = true;
  });
}

Resources

Special thanks to Hannah Van Opstal, Bramus, and Andrew Kean Guan from Google, and Ingrid Reime from NRK for their valuable contributions to this work.