Safari 120Hz ProMotion: Scrolling and Animation Timing

Apple’s ProMotion technology brings adaptive 120Hz refresh rates to compatible Mac, iPhone, and iPad devices, fundamentally changing how the Safari web browser handles visual rendering. While native webpage scrolling automatically benefits from this ultra-smooth 120 frames-per-second capability, JavaScript-driven animations using requestAnimationFrame generally remain locked at 60Hz due to browser power-saving optimizations and legacy web compatibility. This article explains the mechanics behind Safari's 120Hz scrolling, why requestAnimationFrame timing behaves differently, and how web developers must handle variable refresh rates.

How ProMotion Affects Scrolling in Safari

ProMotion displays do not run at a locked 120Hz at all times. Instead, the system dynamically adjusts the refresh rate between 10Hz (or 1Hz on newer iPhones) and 120Hz depending on the content being displayed and the user's input.

In Safari, scrolling is decoupled from the main JavaScript execution thread and is handled directly by the operating system's rendering compositor. This affects scrolling in the following ways:

  • Immediate 120Hz Ramp-Up: As soon as a user touches the screen on an iPhone or iPad, or uses a trackpad on a Mac, Safari signals the display to ramp up to 120Hz, updating the screen every 8.33 milliseconds.
  • Dynamic Deceleration: As the scroll momentum slows down, ProMotion gradually steps down the refresh rate (e.g., to 80Hz, 60Hz, 30Hz, and finally 10Hz when static) to conserve battery life without causing visual stutter.
  • Zero Developer Intervention: Standard CSS overflow scrolling and main-page scrolling automatically run at 120Hz. However, if a website hijacks native scrolling using JavaScript (scroll-jacking), the scrolling smoothness will degrade and drop to the speed of the JavaScript execution thread.

How ProMotion Affects requestAnimationFrame Timing

According to the HTML specification, requestAnimationFrame (rAF) is supposed to match the display’s refresh rate. On a 120Hz monitor, browsers like Chrome and Firefox will trigger requestAnimationFrame callbacks 120 times per second (approximately every 8.33ms).

Safari behaves differently. By default, WebKit (Safari's engine) caps requestAnimationFrame and standard page rendering updates at 60Hz (every 16.67ms), even on 120Hz ProMotion displays.

Safari enforces this 60Hz cap for two primary reasons:

  1. Battery Efficiency: Running complex DOM manipulations, canvas rendering, and JavaScript physics at 120Hz rapidly drains the battery on mobile devices and laptops.
  2. Backwards Compatibility: Historically, many web developers wrote animation loops assuming every screen runs at 60Hz. They incremented animations by a fixed pixel amount per frame rather than calculating elapsed time. On a 120Hz loop, these poorly coded animations run at double speed, breaking games and UI transitions.

Overriding the 60Hz Cap in Safari

Web developers cannot force Safari to run requestAnimationFrame at 120Hz via code. The cap is controlled strictly at the browser level via a WebKit feature flag titled Prefer Page Rendering Updates near 60fps.

  • Default State: Enabled (locks requestAnimationFrame to 60Hz).
  • Disabled State: Unlocks requestAnimationFrame to run at the native 120Hz refresh rate (8.33ms intervals). Users and developers can manually disable this flag in Safari's Feature Flags (or Experimental Features) menu on macOS and iOS.

Writing Frame-Rate Independent Code

Because requestAnimationFrame might run at 60Hz in Safari, 120Hz in Chrome on the same Mac, 120Hz for Safari users who changed their feature flags, or 30Hz when an iPhone is in Low Power Mode, developers must never assume a fixed time between frames.

To ensure animations run at the exact same physical speed regardless of whether Safari is outputting 60Hz or 120Hz, always use the DOMHighResTimeStamp provided by requestAnimationFrame to calculate delta time (the time elapsed since the last frame).

Incorrect Approach (Frame-Dependent)

function animate() {
  // Moves twice as fast at 120Hz compared to 60Hz
  element.style.transform = `translateX(${position += 2}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Correct Approach (Time-Dependent)

let lastTime = performance.now();
let position = 0;
const speed = 120; // Pixels per second

function animate(currentTime) {
  // Calculate time elapsed in seconds
  const deltaTime = (currentTime - lastTime) / 1000;
  lastTime = currentTime;

  // Moves at the exact same speed on 30Hz, 60Hz, and 120Hz
  position += speed * deltaTime;
  element.style.transform = `translateX(${position}px)`;

  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

By relying on delta time and allowing native CSS to handle scrolling rather than JavaScript scroll-jacking, web applications will remain consistent and responsive across all ProMotion devices.