When it comes to web animations there are two phrases people say a lot:
- "use
transform
andopacity
" and - "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.
Here is the result:
position: fixed;top: 0;left: 0;bottom: 0;width: 160px;
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.
- At a certain level the animation dropped frames
- 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
position: absolute;width: 160px;
Notion's sidebar animation has two parts
- A
container
div
withposition: relative
and awidth
animation - And a
sidebar
div
hasposition: absolute
and atransformX
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:
- They skip the
layout
andpaint
sections of the render loop and go straight tocomposite
. - 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 newtransform
andopacity
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.
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-shadow
s. At the time of writing this, I counted 4 animated box-shadow
s in their sidebar, and animating box shadows is expensive
Additionally, there are 7 layers of div
s 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-shadow
s 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
position: fixed;width: 160px;
Linear's UX is similar to Notion, and it also has two parts
- A
spacer
div
isposition: relative
with awidth
animation - And a
sidebar
div
isposition: fixed
with aleft
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.
transform
s 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
position: fixed;top: 0;left: 0;bottom: 0;width: 160px;
Gitlab's UX is similar to the original UX I was working on as there is no vertical shrinking for the unlocked version.
- The
content
div
isposition: relative
with apadding-left
animation - And the
sidebar
div
isposition: fixed
with atranslateX
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 👋