3 min read

Fix Framer Motion layout animations on iOS Safari

I recently used Framer Motion to create a simple layout transition for the navigation bar on talisman.xyz.

On visiting the homepage, the navigation bar is initially full-width and touches the top of the viewport.
But on scroll, the bar shrinks in width, detaches from the top a little and adopts a subtle border with rounded corners.

Motion makes it pretty easy to build these sorts of transitions with its layout property. Nanda Syahrasyad has written an excellent introduction to the concepts behind it at Inside Framer's Magic Motion.

Using a mixture of layout to animate the scale, position and contents of the navigation bar and its children as well as animate for the borders, my first attempt resembled something like this:

export const Navbar = () => {
  const [atPageTop, setAtPageTop] = useState()

  useEffect(() => {
    // --some code to call setAtPageTop based on an IntersectionObserver--
  }, [])

  return (
    <motion.div
      className={cn(
        "fixed top-4 md:top-8",
        atPageTop && "top-0 w-full md:top-0",
      )}
      layoutRoot
    >
      <motion.div
        className="flex items-center"
        layout
        animate={{
          borderWidth: atPageTop ? "0px" : "1px",
          borderColor: atPageTop ? "rgba(0,0,0,0)" : "rgba(0,0,0,0.08)",
          borderRadius: atPageTop ? "0rem" : "0.75rem",
        }}
      >
        {/* the rest of the navbar */}
      </motion.div>
    </motion.div>
  )
}

At first glance, this worked great! With very little work I had a neat-looking transition going. A colleague of mine even remarked that it resembled something like the new liquid glass /dynamic island animations on iPhone.
But I soon found that the animation would be pretty consistently skipped when I scrolled the site in Safari on iOS.

After some debugging and false leads (was it caused by poor performance? or perhaps a mistake with the height of the site scroll container?) I realised the problem is caused by a combination of Safari's address bar and this Motion PR.

To avoid poor performance and potential animation edge-cases, Motion cancels any running layout animations when the viewport is resized.
And Safari resizes the viewport when the user scrolls down the page!
On scroll, the full address bar is shrunk to a small overlay showing just the website's domain.


To solve the problem, I first went looking for a way to tell Motion that I want it to always animate the layout transition for my navigation bar. But unfortunately for me, there's an existing wontfix issue already open on the topic.

After a bit more thinking, I had a workaround in mind:

I already had programmatic control of when the navbar animation is triggered, which happens after the user has scrolled N number of pixels down the page.

What if I delayed the trigger based on whether there were any recent resize events, and waited for the firehose of resize events emitted by Safari's address bar to subside before proceeding to transition the navbar?

I put together a proof of concept using my new favourite async signal processing library rxjs:

/**
 * framer motion cancels any `layout` animations on window resize events
 *
 * safari on iOS has a dynamically-sized browser navigation ui, which upon scrolling
 * down shrinks from a full address bar to a small visual of the current URL
 *
 * if we avoid toggling the <TopNav /> `atPageTop` state while the resize events from this
 * initial page scroll are firing, we can prevent the TopNav animation from being cancelled
 */
const debounceAtPageTop = (setAtPageTop: (atPageTop: boolean) => void) => {
  // use `pushAtPageTop` instead of `setAtPageTop` so that window resize events don't cancel the navbar animation
  // when scrolling down on iOS safari (where the window resizes due to the dynamic browser navigation bar height)
  const [atPageTop$, pushAtPageTop] = (() => {
    const subject = new Subject<boolean>()
    return [subject.asObservable(), (value: boolean) => subject.next(value)] as const
  })()

  // used to clean up all rxjs subscriptions via this one parent subscription
  const debounceAtPageTopSubscription = new Subscription()

  // detect if window was resized in last 300ms
  const resize$ = fromEvent(window, "resize").pipe(share())
  const resizing$ = merge(
    resize$.pipe(map(() => true)),
    resize$.pipe(
      debounceTime(300),
      map(() => false),
    ),
  ).pipe(startWith(false), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }))

  // always keep us subscribed to resize events,
  // otherwise we'll miss any that happen just before we scroll down/up
  debounceAtPageTopSubscription.add(resizing$.subscribe())

  // when we scroll up to / down from the page top, wait for the window to stop resizing (~300ms since last event) before we update the navbar
  debounceAtPageTopSubscription.add(
    atPageTop$
      .pipe(
        // wait for resizing$ to evaluate to false
        debounce(() => resizing$.pipe(filter((isResizing) => !isResizing))),
      )
      .subscribe(
        // update navbar
        setAtPageTop,
      ),
  )

  return [pushAtPageTop, debounceAtPageTopSubscription] as const
}

This function takes the setAtPageTop call from the useState in my Navbar component and returns 2 variables. pushAtPageTop which should be used instead of setAtPageTop to trigger the navbar transition, and debounceAtPageTopSubscription which is used to tidy up the resize event listener when the component unmounts.

I then took this and integrated it into my Navbar component like so:

export const Navbar = () => {
  const [atPageTop, setAtPageTop] = useState()

  useEffect(() => {
    const [pushAtPageTop, debounceAtPageTopSubscription] = debounceAtPageTop(setAtPageTop)
    
    // --some code to call pushAtPageTop based on an IntersectionObserver--

    return () => debounceAtPageTopSubscription.unsubscribe()
  }, [])

  return (
    <motion.div
      className={cn(
        "fixed top-4 md:top-8",
        atPageTop && "top-0 w-full md:top-0",
      )}
      layoutRoot
    >
      <motion.div
        className="flex items-center"
        layout
        animate={{
          borderWidth: atPageTop ? "0px" : "1px",
          borderColor: atPageTop ? "rgba(0,0,0,0)" : "rgba(0,0,0,0.08)",
          borderRadius: atPageTop ? "0rem" : "0.75rem",
        }}
      >
        {/* the rest of the navbar */}
      </motion.div>
    </motion.div>
  )
}

I was surprised how well this approach worked in practice.

When I tried it out on my phone I couldn't even tell the delay was there - the only perceivable difference was that the animation now worked correctly!