Sidebar animation performance

Jul 14, 2023

When it comes to web animations there are two phrases people say a lot:

  1. "use transform and opacity" and
  2. "CSS animations are faster than JS animations"

The rebel inside me has always resented these statements, and in this post, I explore why they are so commonly repeated.

Recently, I created a sidebar animation using a JS library to animate the width. In certain situations, the animation dropped frames. In this post, we will compare my sidebar animation with Notion, Linear, and Gitlab's in a quest for the most performant one.

My goal is for you to leave this post with a better understanding of why these statements are repeated and when you can ignore them.

How this started

This past month I created a sidebar animating the width from 0 to auto with framer-motion.

import { motion } from 'framer-motion'
 
const Sidebar = () => {
    const [isOpen, setIsOpen] = useState(false)
 
    return <motion.div animate={{ width: isOpen ? 'auto' : '0px' }} />
}

Here is the result:

pagesidebarcontent
sidebar
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 160px;
page
display: flex;

framer-motion's ability to animate from 0 to auto is one of my favorite features, and it worked great for the disclosure I made a few months back.

import clsx from 'clsx'
import { MotionConfig, motion } from 'framer-motion'
import clamp from 'lodash.clamp'
import { PointerEvent as ReactPointerEvent, useRef, useState } from 'react'

import { TreeviewComponent } from './treeview'
import { Content } from './content'


const Open = {
    Open: 'open',
    Closed: 'closed',
} as const

type Open = typeof Open[keyof typeof Open]

export default function MakeswiftSidebarPage() {
    const [selected, select] = useState<string | null>(null)
    const [width, setWidth] = useState(250)
    const originalWidth = useRef(width)
    const originalClientX = useRef(width)
    const [isDragging, setDragging] = useState(false)
    const [isOpen, setOpen] = useState<Open>(Open.Open)

    return (
        <MotionConfig
            transition={{ ease: [0.165, 0.84, 0.44, 1], duration: isDragging ? 0 : 0.3 }}
        >
            <div className="flex w-screen justify-start items-start">
                <motion.nav
                    className={clsx(
                        'relative h-screen max-h-screen  bg-[rgb(251,251,250)]',
                        {
                            ['cursor-col-resize']: isDragging,
                        },
                        isDragging
                            ? 'shadow-[rgba(0,0,0,0.2)_-2px_0px_0px_0px_inset]'
                            : 'shadow-[rgba(0,0,0,0.04)_-2px_0px_0px_0px_inset]',
                    )}
                    initial={false}
                    animate={{
                        width: isOpen === Open.Open ? width : 0,
                    }}
                    aria-labelledby="nav-heading"
                >
                    <motion.div
                        className="flex flex-col space-y-2 p-3 h-full overflow-auto"
                        animate={isOpen}
                        variants={{
                            [Open.Open]: {
                                opacity: 1,
                                transition: {
                                    duration: 0.15,
                                    delay: 0.2,
                                },
                            },
                            [Open.Closed]: {
                                opacity: 0,
                                transition: {
                                    duration: 0.15,
                                },
                            },
                        }}
                    > 
                        <h2 id="nav-heading" className="text-lg font-bold">
                            Lorem Ipsum
                        </h2>
                        <TreeviewComponent />
                        <a
                            className="underline text-center"
                            href="https://www.joshuawootonn.com/react-treeview-component"
                        >
                            more about this treeview
                            <svg
                                xmlns="http://www.w3.org/2000/svg"
                                fill="none"
                                viewBox="0 0 24 24"
                                strokeWidth={2.5}
                                stroke="currentColor"
                                className="h-4 w-4 inline-block ml-1 -translate-y-1"
                            >
                                <path
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                    d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
                                />
                            </svg>
                        </a>
                    </motion.div>
                    <button
                        className="absolute bg-white p-1 border-y-2 border-r-2 border-[rgba(0,0,0,0.08)] top-3 -right-[34px]"
                        onClick={() =>
                            setOpen(isOpen =>
                                isOpen === Open.Closed
                                    ? Open.Open
                                    : Open.Closed,
                            )
                        }
                    >
                        <svg
                            xmlns="http://www.w3.org/2000/svg"
                            fill="none"
                            viewBox="0 0 24 24"
                            strokeWidth={1.5}
                            stroke="currentColor"
                            className={clsx(
                                'w-6 h-6 transition-transform ease-[cubic-bezier(0.165,0.84,0.44,1)] duration-300',
                                isOpen === Open.Open
                                    ? 'rotate-180'
                                    : 'rotate-0',
                            )}
                        >
                            <path
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"
                            />
                        </svg>
                    </button>
                    <div className="absolute z-10 right-0 w-0 flex-grow-0 top-0 bottom-0">
                        <div
                            onPointerDown={(e: ReactPointerEvent) => {
                                // this prevents dragging from selecting
                                e.preventDefault()

                                const { ownerDocument } = e.currentTarget
                                originalWidth.current = width
                                originalClientX.current = e.clientX
                                setDragging(true)

                                function onPointerMove(e: PointerEvent) {
                                    if (e.clientX < 50) setOpen(Open.Closed)
                                    else setOpen(Open.Open)

                                    setWidth(
                                        Math.floor(
                                            clamp(
                                                originalWidth.current +
                                                    e.clientX -
                                                    originalClientX.current,
                                                200,
                                                400,
                                            ),
                                        ),
                                    )
                                }

                                function onPointerUp(e: PointerEvent) {
                                    ownerDocument.removeEventListener(
                                        'pointermove',
                                        onPointerMove,
                                    )
                                    setDragging(false)

                                    if (
                                        Math.abs(
                                            e.clientX - originalClientX.current,
                                        ) < 6
                                    ) {
                                        setOpen(value =>
                                            value !== Open.Open
                                                ? Open.Open
                                                : Open.Closed,
                                        )
                                    }
                                }

                                ownerDocument.addEventListener(
                                    'pointermove',
                                    onPointerMove,
                                )
                                ownerDocument.addEventListener(
                                    'pointerup',
                                    onPointerUp,
                                    {
                                        once: true,
                                    },
                                )
                            }}
                            className={clsx(
                                'w-3 h-full cursor-col-resize shrink-0',
                            )}
                        />
                    </div>
                </motion.nav>

                <main className="flex flex-grow max-h-screen">
                    <div className="w-10"></div>
                    <div className="flex flex-col px-5 py-12 flex-grow overflow-auto">
                        <div className="prose mx-auto">
                            <h1>Initial</h1>
                            <Content />
                        </div>
                    </div>
                </main>
            </div>
        </MotionConfig>
    )
}

After shipping, I found that the animation would lag when the main thread was under load.

  1. At a certain level the animation dropped frames
  2. and at another level the animation slowed down

On a clear main thread -> all animations types look the same, and on an over-saturated main thread -> all animations lag. Spend the time to open the Performance Monitor and find the sweet spots where you can tell which animations are faster.

On my M1 Mac mini, the animation dropped frames at 750 and was noticeably slower at 1500.

When I saw the jank in prod I knew that width animations caused reflows on each frame, and that was likely the problem. I wanted to scratch the intellectual itch and understand the tradeoffs in width vs left vs transform and in CSS vs JS animations.

So I recreated Notion's sidebar animation.

Notion

pagecontainercontentsidebar
sidebar
position: absolute;
width: 160px;
container

Notion's sidebar animation has two parts

  1. A container div with position: relative and a width animation
  2. And a sidebar div has position: absolute and a transformX animation

As the container shrinks the sidebar is transformed to the left.

Using absolute positioning removes the sidebar from the document flow. This means that the sidebar content doesn't reflow as the width of the container animates.

When the sidebar is unlocked, a max-height is set and the sidebar is animated down using a transformY.

Here is the example Notion sidebar:

import clsx from 'clsx'
import { MotionConfig, motion } from 'framer-motion'
import clamp from 'lodash.clamp'
import {
    PointerEvent,
    PointerEvent as ReactPointerEvent,
    useRef,
    useState,
} from 'react'

import { TreeviewComponent } from './treeview'
import { Content } from './content'

const Open = {
    Locked: 'locked',
    Unlocked: 'unlocked',
    Hidden: 'hidden',
} as const

type Open = typeof Open[keyof typeof Open]

export default function NotionSidebarPage() {
    const [selected, select] = useState<string | null>(null)
    const [width, setWidth] = useState(250)
    const originalWidth = useRef(width)
    const originalClientX = useRef(width)
    const [isDragging, setDragging] = useState(false)
    const [isOpen, setOpen] = useState<Open>(Open.Locked)

    return (
        <MotionConfig
            transition={{ ease: [0.165, 0.84, 0.44, 1], duration: 0.3 }}
        >
            <div
                className="flex overflow-hidden"
                onPointerMove={(e: PointerEvent) => {
                    if (isDragging) return

                    if (e.clientX < 80) {
                        setOpen(isOpen =>
                            isOpen === Open.Hidden ? Open.Unlocked : isOpen,
                        )
                        return
                    }

                    let ele = e.target as Element | null
                    let called = false

                    while (ele != null && ele !== e.currentTarget) {
                        if (ele.getAttribute('data-show-unlocked-sidebar')) {
                            called = true
                            setOpen(isOpen =>
                                isOpen === Open.Hidden ? Open.Unlocked : isOpen,
                            )
                            break
                        }

                        ele = ele.parentElement
                    }

                    if (called === false)
                        setOpen(isOpen =>
                            isOpen === Open.Unlocked ? Open.Hidden : isOpen,
                        )
                }}
                onPointerLeave={(e: PointerEvent) => {
                    setOpen(isOpen =>
                        isOpen === Open.Unlocked ? Open.Hidden : isOpen,
                    )
                }}
            >
                <motion.nav
                    className={clsx(
                        'flex flex-col relative h-screen max-h-screen flex-shrink-0',
                        {
                            ['cursor-col-resize']: isDragging,
                        },
                    )}
                    initial={false}
                    animate={{
                        width: isOpen === Open.Locked ? width : 0,
                    }}
                    data-show-unlocked-sidebar
                >
                    <motion.div
                        className={clsx(
                            `absolute top-0 left-0 bottom-0 bg-[rgb(251,251,250)]`,
                            isOpen === Open.Locked
                                ? 'h-screen'
                                : `h-[calc(100vh-120px)]`,
                        )}
                        initial={false}
                        animate={{
                            boxShadow: `${
                                isDragging
                                    ? 'rgba(0,0,0,0.2)'
                                    : 'rgba(0,0,0,0.04)'
                            } -2px 0px 0px 0px inset, rgba(0,0,0,0.04) 0px ${
                                isOpen === Open.Locked ? '0' : '-2'
                            }px 0px 0px inset, rgba(0,0,0,0.04) 0px ${
                                isOpen === Open.Locked ? '0' : '2'
                            }px 0px 0px inset`,
                            borderTopRightRadius:
                                isOpen === Open.Locked ? '0px' : '3px',
                            borderBottomRightRadius:
                                isOpen === Open.Locked ? '0px' : '3px',
                            top: isOpen === Open.Locked ? 0 : 80,
                            width,
                            x: isOpen === Open.Hidden ? -width + 20 : 0,
                            opacity: isOpen === Open.Hidden ? 0 : 1,
                        }}
                    >
                        <div className="flex flex-col space-y-2 p-3 h-full overflow-auto">
                            <h2 id="nav-heading" className="text-lg font-bold">
                                Lorem Ipsum
                            </h2>
                            <TreeviewComponent />
                            <a
                                className="underline text-center"
                                href="https://www.joshuawootonn.com/react-treeview-component"
                            >
                                more about this treeview
                                <svg
                                    xmlns="http://www.w3.org/2000/svg"
                                    fill="none"
                                    viewBox="0 0 24 24"
                                    strokeWidth={2.5}
                                    stroke="currentColor"
                                    className="h-4 w-4 inline-block ml-1 -translate-y-1"
                                >
                                    <path
                                        strokeLinecap="round"
                                        strokeLinejoin="round"
                                        d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
                                    />
                                </svg>
                            </a>
                            <div className="absolute z-10 right-0 w-0 flex-grow-0 top-0 bottom-0">
                                <div
                                    onPointerDown={(e: ReactPointerEvent) => {
                                        // this prevents dragging from selecting
                                        e.preventDefault()

                                        const { ownerDocument } =
                                            e.currentTarget
                                        originalWidth.current = width
                                        originalClientX.current = e.clientX
                                        setDragging(true)

                                        function onPointerMove(
                                            e: globalThis.PointerEvent,
                                        ) {
                                            setWidth(
                                                Math.floor(
                                                    clamp(
                                                        originalWidth.current +
                                                            e.clientX -
                                                            originalClientX.current,
                                                        200,
                                                        400,
                                                    ),
                                                ),
                                            )
                                        }

                                        function onPointerUp(
                                            e: globalThis.PointerEvent,
                                        ) {
                                            ownerDocument.removeEventListener(
                                                'pointermove',
                                                onPointerMove,
                                            )
                                            setDragging(false)

                                            if (
                                                Math.abs(
                                                    e.clientX -
                                                        originalClientX.current,
                                                ) < 6
                                            ) {
                                                setOpen(value =>
                                                    value !== Open.Locked
                                                        ? Open.Locked
                                                        : Open.Hidden,
                                                )
                                            }
                                        }

                                        ownerDocument.addEventListener(
                                            'pointermove',
                                            onPointerMove,
                                        )
                                        ownerDocument.addEventListener(
                                            'pointerup',
                                            onPointerUp,
                                            {
                                                once: true,
                                            },
                                        )
                                    }}
                                    className={clsx(
                                        'w-3 h-full cursor-col-resize shrink-0',
                                    )}
                                />
                            </div>
                        </div>
                    </motion.div>
                </motion.nav>

                <div className="flex flex-col flex-grow max-h-screen">
                    <header className="flex flex-row w-full items-center space-x-3 p-3 shadow-[rgba(0,0,0,0.04)_0px_-2px_0px_0px_inset]">
                        <button
                            className="self-end"
                            onClick={() =>
                                setOpen(isOpen =>
                                    isOpen === Open.Unlocked
                                        ? Open.Locked
                                        : Open.Unlocked,
                                )
                            }
                            data-show-unlocked-sidebar
                        >
                            <motion.svg
                                xmlns="http://www.w3.org/2000/svg"
                                fill="none"
                                viewBox="0 0 24 24"
                                strokeWidth={1.5}
                                stroke="currentColor"
                                className="w-6 h-6"
                                initial={false}
                                animate={{
                                    rotate: isOpen === Open.Locked ? 180 : 0,
                                }}
                            >
                                <path
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                    d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"
                                />
                            </motion.svg>
                        </button>

                        <div className="translate-y-[1px]">Lorem Ipsum</div>
                    </header>
                    <main className="w-full px-5 py-12 mx-auto overflow-auto">
                        <div className="prose mx-auto">
                            <h1>Notion</h1>
                            <Content />
                        </div>
                    </main>
                </div>
            </div>
        </MotionConfig>
    )
}

While Notion is preventing as much reflow as possible and using transform, I've seen it lag before. Here are my best guesses at why:

What makes transform/opacity animations fast?

Transform and opacity animations are fast for two reasons:

  1. They skip the layout and paint sections of the render loop and go straight to composite.
  2. They can be hardware/GPU accelerated. The CPU no longer has to send new images to the GPU for every composite phase. The GPU can reuse the old images and compose them with the new transform and opacity values.

If Notion is using transform and opacity, then why does their main thread spike during the sidebar transition? Shouldn't this work be offloaded to the GPU since these properties are hardware accelerated?

JS vs CSS animations

While all transform/opacity animations take advantage of #1 from above, only CSS animations and the web animation API (WAAPI) take advantage of #2.

Table of what animation types can be accelerated by the compositor thread

Screenshot from https://webperf.tips.

Since Notion is using JS animations, the animation is likely using requestAnimationFrame, which isn't hardware accelerated and requires CPU time for every frame it creates.

To double-check this I made a demo. As you increase the number of cars in the PerfSlayer you can see the framer-motion box drops frames well before the CSS box. This is because framer-motion uses requestAnimationFrame for all animations types other than opacity.

import clsx from 'clsx'
import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { animate } from 'motion'

import { PerfSlayer } from './perf-slayer'

export default function App() {
    const [renderPerfSlayer, togglePerfSlayer] = useState(true)
    const [isCSSOpen, setIsCSSOpen] = useState(false)
    const [isFramerMotionOpen, setIsFramerMotionOpen] = useState(false)
    const [isMotionOneOpen, setIsMotionOneOpen] = useState(false)

    useEffect(() => {
        animate(
            '[data-motion-one-box]',
            {
                transform: isMotionOneOpen
                    ? 'translateX(500px)'
                    : 'translateX(0px)',
            },
            {
                duration: 0.15,
                easing: 'ease-in-out',
            },
        )
    }, [isMotionOneOpen])

    return (
        <div className="relative z-0 m-3 space-y-2">
            <h1 className="text-xl font-bold">
                CSS vs framer-motion (requestAnimationFrame) vs
                Motion One (WAAPI)
            </h1>
            <div className="h-100">
                <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
                    <button
                        className="px-2 py-1 border-black border-2"
                        onClick={() => setIsCSSOpen(count => !count)}
                    >
                        CSS 
                    </button>
                    <button
                        className="px-2 py-1 border-black border-2"
                        onClick={() => setIsFramerMotionOpen(count => !count)}
                    >
                        framer-motion 
                    </button>
               
                    <button
                        className="px-2 py-1 border-black border-2"
                        onClick={() => setIsMotionOneOpen(count => !count)}
                    >
                        motion one 
                    </button>
                    <button
                        className="px-2 py-1 justify-items-end border-black border-2"
                        onClick={() => togglePerfSlayer(count => !count)}
                    >
                        Toggle PerfSlayer
                    </button>
                </div>
                <div
                    className={clsx(
                        'bg-black text-white absolute top-[200px] sm:top-[130px] left-0 w-[100px] h-[100px] transition-all ease-in-out ',
                        isCSSOpen ? 'translate-x-[500px]' : 'translate-x-0 ',
                    )}
                >
                    CSS
                </div>

                <motion.div
                    className="bg-black text-white absolute top-[310px] sm:top-[240px] left-0 w-[100px] h-[100px]"
                    animate={{
                        x: isFramerMotionOpen ? 500 : 0,
                    }}
                    transition={{
                        ease: 'easeInOut',
                        duration: 0.15,
                    }}
                >
                    framer-motion
                </motion.div>
                <div
                    className={
                        'bg-black text-white absolute top-[420px] sm:top-[350px] left-0 w-[100px] h-[100px] transition-all ease-in-out '
                    }
                    data-motion-one-box
                >
                    motion one
                </div>
            </div>

            {renderPerfSlayer && (
                <PerfSlayer className="h-96 w-full max-w-xl mx-auto mb-12" />
            )}
        </div>
    )
}

Open this example (in a different tab) to see the difference in the main thread during each animation.

I don't know what JS animation library Notion is using but I do know that their transform animations aren't hardware accelerated because they don't show in the animation pane.

Let's bring this back to "Why does Notion's sidebar lag?" When the main thread is saturated, each transform must wait for the main thread to be free to update its value. If there is a lot of existing CPU activity and the computation takes more than 16.66ms then we get less than 60fps.

WAAPI

You may have noticed from the diagram that WAAPI animations can be hardware accelerated. I included Motion One (a WAAPI library) in the demo so you could see it alongside CSS and framer-motion.

Why are we still using requestAnimationFrame if WAAPI is hardware accelerated?

At this point, I don't understand the limitation of WAAPI. When I understand more I'll write another post with details.

Motion One does have some great details here on the tradeoffs.

Animating box-shadow / general complexity

Another reason Notion's sidebar might lag is their heavy usage of box-shadows. At the time of writing this, I counted 4 animated box-shadows in their sidebar, and animating box shadows is expensive

Additionally, there are 7 layers of divs between their sidebar container and their sidebar content. Not that this layering would affect the transform, but it probably points to some bigger complexity of the code running when you toggle the sidebar.

According to the Motion One docs, hardware-accelerated filter animations are coming. So Notion's animated sidebar, will benefit from changing its box-shadows to be filter: "drop-shadow($$$$)" animations in the future.

(I couldn't find a ton of info on this. If you have more details please share them and I'll update this section!)

Linear

pagecontentsidebarspacer
sidebar
position: fixed;
width: 160px;
spacer

Linear's UX is similar to Notion, and it also has two parts

  1. A spacer div is position: relative with a width animation
  2. And a sidebar div is position: fixed with a left animation

I love using a spacer instead of a container. There are a lot fewer gotcha's to worry about when there are fewer layers, and having a div do one thing is nice and simple.

import clsx from 'clsx'
import { MotionConfig, motion } from 'framer-motion'
import clamp from 'lodash.clamp'
import {
    PointerEvent,
    PointerEvent as ReactPointerEvent,
    useRef,
    useState,
} from 'react'

import { TreeviewComponent } from './treeview'
import { Content } from './content'

const Open = {
    Open: 'open',
    Closed: 'closed',
} as const

type Open = typeof Open[keyof typeof Open]

const Locked = {
    Locked: 'locked',
    Unlocked: 'unlocked',
} as const

type Locked = typeof Locked[keyof typeof Locked]

export default function LinearSidebarPage() {
    const [selected, select] = useState<string | null>(null)
    const [width, setWidth] = useState(250)
    const originalWidth = useRef(width)
    const originalClientX = useRef(width)
    const [isDragging, setDragging] = useState(false)
    const [locked, setLocked] = useState<Locked>(Locked.Locked)
    const [open, setOpen] = useState<Open>(Open.Open)

    return (
        <div
            className="flex"
            onPointerMove={(e: PointerEvent) => {
                if (isDragging) return

                if (e.clientX < 80) {
                    setOpen(Open.Open)
                    return
                }

                let ele = e.target as Element | null
                let called = false

                while (ele != null && ele !== e.currentTarget) {
                    if (ele.getAttribute('data-show-unlocked-sidebar')) {
                        called = true
                        setOpen(Open.Open)
                        break
                    }

                    ele = ele.parentElement
                }

                if (called === false)
                    setOpen(open =>
                        locked === Locked.Unlocked ? Open.Closed : open,
                    )
            }}
            onPointerLeave={(e: PointerEvent) => {
                setOpen(open =>
                    locked === Locked.Unlocked ? Open.Closed : open,
                )
            }}
        >
            <MotionConfig
                transition={{ ease: [0.165, 0.84, 0.44, 1], duration: 0.3 }}
            >
                <motion.div
                    className="flex-shrink-0"
                    initial={false}
                    animate={{
                        width:
                            locked === Locked.Locked && open === Open.Open
                                ? width
                                : 0,
                    }}
                    transition={{
                        ease: [0.165, 0.84, 0.44, 1],
                        duration: isDragging ? 0 : 0.3,
                    }}
                />
                <motion.nav
                    data-show-unlocked-sidebar
                    className={
                        'fixed top-0 left-0 bottom-0 bg-[rgb(251,251,250)]'
                    }
                    initial={false}
                    animate={{
                        boxShadow: `${
                            isDragging ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.04)'
                        } -2px 0px 0px 0px inset, rgba(0,0,0,0.04) 0px ${
                            locked === Locked.Locked ? '0' : '-2'
                        }px 0px 0px inset, rgba(0,0,0,0.04) 0px ${
                            locked === Locked.Locked ? '0' : '2'
                        }px 0px 0px inset, rgba(0,0,0,0.04) ${
                            locked === Locked.Locked ? '0' : '2'
                        }px 0px 0px 0px inset`,
                        borderRadius: locked === Locked.Locked ? '0px' : '5px',
                        top: locked === Locked.Locked ? 0 : 53,
                        width,
                        left:
                            open === Open.Open
                                ? locked === Locked.Locked
                                    ? 0
                                    : 5
                                : -width - 10,
                        bottom: locked === Locked.Locked ? 0 : 5,
                        transition: {
                            ease: [0.165, 0.84, 0.44, 1],
                            width: {
                                ease: [0.165, 0.84, 0.44, 1],
                                duration: isDragging ? 0 : 0.3,
                            },
                            left: {
                                ease: [0.165, 0.84, 0.44, 1],
                                duration: isDragging ? 0 : 0.3,
                            },
                        },
                    }}
                >
                    <div className="flex flex-col space-y-2 p-3 h-full overflow-auto">
                    <h2 id="nav-heading" className="text-lg font-bold">
                        Lorem Ipsum
                    </h2>
                    <TreeviewComponent />
                    <a
                        className="underline text-center"
                        href="https://www.joshuawootonn.com/react-treeview-component"
                    >
                        more about this treeview
                        <svg
                            xmlns="http://www.w3.org/2000/svg"
                            fill="none"
                            viewBox="0 0 24 24"
                            strokeWidth={2.5}
                            stroke="currentColor"
                            className="h-4 w-4 inline-block ml-1 -translate-y-1"
                        >
                            <path
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
                            />
                        </svg>
                    </a>
                        <div className="absolute z-10 right-0 w-0 flex-grow-0 top-0 bottom-0">
                            <div
                                onPointerDown={(e: ReactPointerEvent) => {
                                    // this prevents dragging from selecting
                                    e.preventDefault()

                                    const { ownerDocument } = e.currentTarget
                                    originalWidth.current = width
                                    originalClientX.current = e.clientX
                                    setDragging(true)

                                    function onPointerMove(
                                        e: globalThis.PointerEvent,
                                    ) {
                                        if (e.clientX < 50) {
                                            setOpen(Open.Closed)
                                        } else {
                                            setOpen(Open.Open)
                                        }

                                        setWidth(
                                            Math.floor(
                                                clamp(
                                                    originalWidth.current +
                                                        e.clientX -
                                                        originalClientX.current,
                                                    200,
                                                    400,
                                                ),
                                            ),
                                        )
                                    }

                                    function onPointerUp(
                                        e: globalThis.PointerEvent,
                                    ) {
                                        ownerDocument.removeEventListener(
                                            'pointermove',
                                            onPointerMove,
                                        )
                                        setDragging(false)

                                        if (
                                            Math.abs(
                                                e.clientX -
                                                    originalClientX.current,
                                            ) < 6
                                        ) {
                                            setLocked(isLocked => {
                                                if (
                                                    isLocked === Locked.Locked
                                                ) {
                                                    setOpen(Open.Closed)
                                                    return Locked.Unlocked
                                                } else {
                                                    setOpen(Open.Open)
                                                    return Locked.Locked
                                                }
                                            })
                                        }
                                    }

                                    ownerDocument.addEventListener(
                                        'pointermove',
                                        onPointerMove,
                                    )
                                    ownerDocument.addEventListener(
                                        'pointerup',
                                        onPointerUp,
                                        {
                                            once: true,
                                        },
                                    )
                                }}
                                className={clsx(
                                    'w-3 h-full cursor-col-resize shrink-0',
                                )}
                            />
                        </div>
                    </div>
                </motion.nav>

                <div className="flex flex-col flex-grow max-h-screen">
                    <header className="flex flex-row w-full items-center space-x-3 p-3 shadow-[rgba(0,0,0,0.04)_0px_-2px_0px_0px_inset]">
                        <motion.button
                            className="self-end"
                            onClick={() =>
                                setLocked(isLocked => {
                                    if (isLocked === Locked.Locked) {
                                        setOpen(Open.Closed)
                                        return Locked.Unlocked
                                    } else {
                                        setOpen(Open.Open)
                                        return Locked.Locked
                                    }
                                })
                            }
                            data-show-unlocked-sidebar
                        >
                            <motion.svg
                                xmlns="http://www.w3.org/2000/svg"
                                fill="none"
                                viewBox="0 0 24 24"
                                strokeWidth={1.5}
                                stroke="currentColor"
                                className="w-6 h-6"
                                initial={false}
                                animate={{
                                    rotate:
                                        locked === Locked.Locked &&
                                        open === Open.Open
                                            ? 180
                                            : 0,
                                }}
                            >
                                <path
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                    d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"
                                />
                            </motion.svg>
                        </motion.button>

                        <div className="translate-y-[1px]">Lorem Ipsum</div>
                    </header>
                    <main className="w-full px-5 py-12 mx-auto overflow-auto">
                        <div className="prose mx-auto">
                            <h1>Linear</h1>
                            <Content />
                        </div>
                    </main>
                </div>
            </MotionConfig>
        </div>
    )
}

left vs transform

Linear's sidebar features a JS animation using left instead of transform. This causes a layout shift where there doesn't need to be, because left triggers the layout phase of the render loop.

The "Rendering" section of devtools has a nice way to visualize this:

I have never seen Linear's sidebar animation lag. Maybe this is because it has a simple structure that prevents reflows from being expensive.

That said they do miss out on pixel blurring that happens for transform properties. transforms essentially have anti-aliasing, while top/right/bottom/left don't.

As a transform moves, the browser will interpolate the pixels between the start and end positions rendering blurred edges.

Paul Irish called this the stair-stepping effect.

import clsx from 'clsx'
import { useState } from 'react'

function App() {
    const [isTRBL, setIsTRBL] = useState(false)
    const [isTranslate, setIsTranslate] = useState(false)

    return (
        <div className="m-3 space-y-4">
            <h1 className="text-xl font-bold">Stair stepping demo</h1>
            <p>
                Notice the top/left example jumps between pixels, while the
                translate smoothly transitions between them.
            </p>
            <div className="space-x-2 flex justify-start items-start">
                <button
                    className="px-2 py-1 border-black border-2"
                    onClick={() => setIsTRBL(count => !count)}
                >
                    Toggle top/left
                </button>
                <button
                    className="px-2 py-1 border-black border-2"
                    onClick={() => setIsTranslate(count => !count)}
                >
                    Toggle translate
                </button>
            </div>
            <div className="h-[250px] relative z-0">
                <div
                    className={clsx(
                        'bg-black text-white absolute w-[100px] h-[100px] transition-all duration-[3000ms] ease-linear',
                        isTRBL ? 'left-[100px] top-[3px]' : 'left-0 top-0',
                    )}
                >
                    top/left
                </div>
                <div
                    className={clsx(
                        'bg-black text-white absolute top-[110px] left-0 w-[100px] h-[100px] transition-all duration-[3000ms] ease-linear',
                        isTranslate
                            ? 'translate-x-[100px] translate-y-[3px]'
                            : 'translate-x-0 translate-y-0',
                    )}
                >
                    translate
                </div>
            </div>
        </div>
    )
}

export default App

As you toggle the top/left example you can see the box jump from pixel to pixel, while the transform example smoothly moves from start to finish.

Even though Linear's sidebar uses left, when I use the PerfSlayer it drops frames around the same time that Notion does. This lead me to realize that hardware acceleration is the biggest thing both Notion and Linear are missing out on.

Gitlab

pagesidebarcontent
sidebar
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 160px;
content

Gitlab's UX is similar to the original UX I was working on as there is no vertical shrinking for the unlocked version.

  1. The content div is position: relative with a padding-left animation
  2. And the sidebar div is position: fixed with a translateX animation
import clsx from 'clsx'
import clamp from 'lodash.clamp'
import { PointerEvent as ReactPointerEvent, useRef, useState } from 'react'

import { TreeviewComponent } from './treeview'
import { Content } from './content'

const Open = {
    Open: 'open',
    Closed: 'closed',
} as const

type Open = typeof Open[keyof typeof Open]

export default function GitlabSidebarPage() {
    const [selected, select] = useState<string | null>(null)
    const [width, setWidth] = useState(250)
    const originalWidth = useRef(width)
    const originalClientX = useRef(width)
    const [isDragging, setDragging] = useState(false)
    const [isOpen, setOpen] = useState<Open>(Open.Open)

    return (
        <div className="flex w-screen justify-start items-start">
            <nav
                className={clsx(
                    'fixed top-0 bottom-0 left-0 flex flex-col space-y-2 h-screen max-h-screen flex-shrink-0 bg-[rgb(251,251,250)] transition-transform ease-[cubic-bezier(0.165,0.84,0.44,1)] duration-300',
                    {
                        ['cursor-col-resize']: isDragging,
                    },
                    isDragging
                        ? 'shadow-[rgba(0,0,0,0.2)_-2px_0px_0px_0px_inset]'
                        : 'shadow-[rgba(0,0,0,0.04)_-2px_0px_0px_0px_inset]',
                    isOpen === Open.Open
                        ? 'translate-x-0'
                        : '-translate-x-full',
                )}
                aria-labelledby="nav-heading"
                style={{ width }}
            >
                <div className="flex flex-col space-y-2 p-3 h-full overflow-auto">
                    <h2 id="nav-heading" className="text-lg font-bold">
                        Lorem Ipsum
                    </h2>
                    <TreeviewComponent />
                    <a
                        className="underline text-center"
                        href="https://www.joshuawootonn.com/react-treeview-component"
                    >
                        more about this treeview
                        <svg
                            xmlns="http://www.w3.org/2000/svg"
                            fill="none"
                            viewBox="0 0 24 24"
                            strokeWidth={2.5}
                            stroke="currentColor"
                            className="h-4 w-4 inline-block ml-1 -translate-y-1"
                        >
                            <path
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
                            />
                        </svg>
                    </a>
                </div>
                <button
                    className="absolute bg-white p-1 border-y-2 border-r-2 border-[rgba(0,0,0,0.08)] text-slate-600 -right-[34px]"
                    onClick={() =>
                        setOpen(isOpen =>
                            isOpen === Open.Closed ? Open.Open : Open.Closed,
                        )
                    }
                >
                    <svg
                        xmlns="http://www.w3.org/2000/svg"
                        fill="none"
                        viewBox="0 0 24 24"
                        strokeWidth={1.5}
                        stroke="currentColor"
                        className={clsx(
                            'w-6 h-6 transition-transform ease-[cubic-bezier(0.165,0.84,0.44,1)] duration-300',
                            isOpen === Open.Open ? 'rotate-180' : 'rotate-0',
                        )}
                    >
                        <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"
                        />
                    </svg>
                </button>

                <div className="absolute z-10 right-0 w-0 flex-grow-0 top-0 bottom-0">
                    <div
                        onPointerDown={(e: ReactPointerEvent) => {
                            // this prevents dragging from selecting
                            e.preventDefault()

                            const { ownerDocument } = e.currentTarget
                            originalWidth.current = width
                            originalClientX.current = e.clientX
                            setDragging(true)

                            function onPointerMove(e: PointerEvent) {
                                if (e.clientX < 50) setOpen(Open.Closed)
                                else setOpen(Open.Open)

                                setWidth(
                                    Math.floor(
                                        clamp(
                                            originalWidth.current +
                                                e.clientX -
                                                originalClientX.current,
                                            200,
                                            400,
                                        ),
                                    ),
                                )
                            }

                            function onPointerUp() {
                                ownerDocument.removeEventListener(
                                    'pointermove',
                                    onPointerMove,
                                )
                                setDragging(false)
                            }

                            ownerDocument.addEventListener(
                                'pointermove',
                                onPointerMove,
                            )
                            ownerDocument.addEventListener(
                                'pointerup',
                                onPointerUp,
                                {
                                    once: true,
                                },
                            )
                        }}
                        className={clsx(
                            'w-3 h-full cursor-col-resize shrink-0',
                        )}
                    />
                </div>
            </nav>

            <main
                style={{ paddingLeft: isOpen === Open.Open ? width : 0 }}
                className={clsx(
                    'flex flex-grow max-h-screen',
                    isDragging
                        ? 'transition-none'
                        : 'transition-all ease-[cubic-bezier(0.165,0.84,0.44,1)] duration-300',
                )}
            >
                <div className="flex flex-col px-5 py-12 flex-grow overflow-auto">
                    <div className="prose mx-auto">
                        <h1>Gitlab</h1>
                        <Content />
                    </div>
                </div>
            </main>
        </div>
    )
}

Not going to lie Gitlab's UI has too many spinners for me to be a big fan, but their sidebar animation is the best I could find.

Hardware-accelerated animations

Their animations are in CSS and are the only ones that don't drop frames at "6x slowdown".

Another advantage of doing CSS/WAAPI is they show up in the animation pane and performance pane while requestAnimationFrame animations don't.

This is one advantage that I wasn't expecting from comparing all these animations. Since CSS animations use the platform the browser tooling is much better.

At this point, I would jump back into the examples and compare the animations when the PerfSlayer is set to 1000+.

When I was creating the PerfSlayer for this post I thought about adding a "speed" feature where you could toggle between left and transformX. To my surprise, both modes dropped frames at the same rate. I now know that was because I was using requestAnimationFrame to change their values, which means transform wasn't hardware accelerated.

My takeaway is that JS animation libraries using requestAnimationFrame will always struggle when the main thread is saturated and to be more careful where I use them. For my next iteration of the sidebar, I'll be using a CSS transition to get all the benefits of hardware acceleration.

I hope you learned a thing or two about web animations and how sidebars are created. If you did please don't forget to share the post with others. Until next time 👋

Subscribe to the newsletter

A monthly no filler update.

Contact me at