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:
- Battery Efficiency: Running complex DOM manipulations, canvas rendering, and JavaScript physics at 120Hz rapidly drains the battery on mobile devices and laptops.
- 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
requestAnimationFrameto 60Hz). - Disabled State: Unlocks
requestAnimationFrameto 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.