How to achieve smooth frame rate independent animations

I have the following animation that I would like to make smooth and frame rate independent:

const duration = 25;
const friction = 0.68;
const target = 400;

let velocity = 0;
let location = 0;

function update() {
  const displacement = target - location;

  velocity += displacement / duration;
  velocity *= friction;
  location += velocity;

  element.style.transform = `translate3d(${location}px, 0px, 0px)`;
}

This is what I want to achieve:

  1. The animation duration should be the same regardless of the device refresh rate whether it’s 30Hz, 60Hz or 120Hz or higher. Small insignificant fluctuations in milliseconds are acceptable.
  2. The animation should be smooth on all devices with 60Hz refresh rate or higher.
  3. The animation behavior should remain the same while achieving point 1-2 above.

How do I go about solving this?


What I’ve tried

I’ve tried implementing the by many devs praised technique in the Fix Your Timestep! article, which decouples the update process and rendering. This is supposed to make the animation smooth regardless of the device refresh rate:

function runAnimation() {
  const squareElement = document.getElementById('square');
  const timeStep = 1000 / 60;
  const duration = 25;
  const friction = 0.68;
  const target = 400;
  const settleThreshold = 0.001;

  let location = 0;
  let previousLocation = 0;
  let velocity = 0;
  let lastTimeStamp = 0;
  let lag = 0;
  let animationFrame = 0;

  function animate(timeStamp) {
    if (!animationFrame) return;
    if (!lastTimeStamp) lastTimeStamp = timeStamp;

    const elapsed = timeStamp - lastTimeStamp;
    lastTimeStamp = timeStamp;
    lag += elapsed;

    while (lag >= timeStep) {
      update();
      lag -= timeStep;
    }

    const lagOffset = lag / timeStep;
    render(lagOffset);

    if (animationFrame) {
      animationFrame = requestAnimationFrame(animate);
    }
  }

  function update() {
    const displacement = target - location;
    previousLocation = location;

    velocity += displacement / duration;
    velocity *= friction;
    location += velocity;
  }

  function render(lagOffset) {
    const interpolatedLocation =
      location * lagOffset + previousLocation * (1 - lagOffset);

    squareElement.style.transform = `translate3d(${interpolatedLocation}px, 0px, 0px)`;

    if (Math.abs(target - location) < settleThreshold) {
      cancelAnimationFrame(animationFrame);
    }
  }

  animationFrame = requestAnimationFrame(animate);
}

runAnimation();
body {
  background-color: black;
}

#square {
  background-color: cyan;
  width: 100px;
  height: 100px;
}
<div id="square"></div>

…However, devs claim that the animation runs smoothly on devices with 60Hz refresh rate but that the animation is stuttering/is choppy on devices with 120Hz refresh rates and up. So I tried to plot the animation curve on different refresh rates to see if there’s something obvious that I’m doing wrong, but judging from the charts, it seems like the animation should be smooth regardless of refresh rate?

function plotCharts() {
  function randomIntFromInterval(min, max) {
    return Math.floor(Math.random() * (max - min + 1) + min);
  }

  function simulate(hz, color) {
    const chartData = [];

    const targetLocation = 400;
    const settleThreshold = 0.001;

    const duration = 25;
    const friction = 0.68;
    const fixedFrameRate = 1000 / 60;
    const deltaTime = 1000 / hz;

    let location = 0;
    let previousLocation = location;
    let interpolatedLocation = location;

    let velocity = 0;
    let timeElapsed = 0;
    let lastTimeStamp = 0;
    let lag = 0;

    function update() {
      const displacement = targetLocation - location;
      previousLocation = location;

      velocity += displacement / duration;
      velocity *= friction;
      location += velocity;
    }

    function shouldSettle() {
      const displacement = targetLocation - location;
      return Math.abs(displacement) < settleThreshold;
    }

    while (!shouldSettle()) {
      const timeStamp = performance.now();

      if (!lastTimeStamp) {
        lastTimeStamp = timeStamp;
        update();
      }

      /* 
      Number between -1 to 1 including numbers with 3 decimal points
      The deltaTimeFluctuation variable simulates the fluctuations of deltaTime that real devices have. For example, if the device has a refresh rate of 60Hz, the time between frames will almost never be exactly 16,666666666666667 (1000 / 60).
      */
      const deltaTimeFluctuation =
        randomIntFromInterval(-1000, 1000) / 1000;
      const elapsed = deltaTime + deltaTimeFluctuation;

      lastTimeStamp = timeStamp;
      lag += elapsed;

      while (lag >= fixedFrameRate) {
        update();
        lag -= fixedFrameRate;
      }

      const lagOffset = lag / fixedFrameRate;

      interpolatedLocation =
        location * lagOffset + previousLocation * (1 - lagOffset);

      timeElapsed += elapsed;

      chartData.push({
        time: parseFloat((timeElapsed / 1000).toFixed(2)),
        position: interpolatedLocation,
      });
    }

    const timeData = chartData.map((point) => point.time);
    const positionData = chartData.map((point) => point.position);

    const canvas = document.createElement("canvas");
    canvas.width = 600;
    canvas.height = 400;
    const ctx = canvas.getContext("2d");
    document.body.appendChild(canvas);

    const chart = new Chart(ctx, {
      type: "line",
      data: {
        labels: timeData,
        datasets: [{
          label: `${hz}Hz (with Interpolation)`,
          data: positionData,
          borderColor: color,
          fill: false,
        }, ],
      },
      options: {
        scales: {
          x: { title: { display: true, text: "Time (seconds)" } },
          y: { title: { display: true, text: "Position (px)" } },
        },
      },
    });
  }

  const simulations = [{
      hz: 30,
      color: "yellow"
    },
    {
      hz: 60,
      color: "blue"
    },
    {
      hz: 120,
      color: "red"
    },
    {
      hz: 240,
      color: "cyan"
    },
    {
      hz: 360,
      color: "purple"
    },
  ];

  simulations.forEach((simulation) => {
    simulate(simulation.hz, simulation.color);
  });
}

plotCharts()
body {
  background-color: black;
}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

I’m probably doing something wrong so any help is appreciated! Please note that alternative approaches to the fixed time step with render interpolation are welcome, as long as the animation behaves the same.