Welcome back 👋
This post is the third in final post on creating a treeview component in React.
In part 1 we created a mouse interactive treeview component.
Then in part 2 we added keyboard navigation and accessibility.
In this post, we will
Animate the arrow indicating the collapsed state of our Node
Animate the "enter" and "exit" animations as Node
s get added and removed from the DOM
And cover an edgecase in keyboard navigation that appears when you add exit animations
This post is much more "show" than it is "tell",
for a more detailed explanation of how to use framer motion check out some other posts I have on the topic.
Animate the arrow with framer motion:
export function Arrow({ open, className }: IconProps) {
return (
- <svg
+ <motion.svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className={clsx(
'origin-center',
- open ? 'rotate-90' : 'rotate-0',
className,
)}
+ animate={{ rotate: open ? 90 : 0 }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
- </svg>
+ </motion.svg>
)
}
Create a layout animation to fade children in and out.
+ <AnimatePresence initial={false}>
{children?.length && isOpen && (
- <ul
+ <motion.ul
+ initial={{
+ height: 0,
+ opacity: 0,
+ }}
+ animate={{
+ height: 'auto',
+ opacity: 1,
+ transition: {
+ height: {
+ duration: 0.25,
+ },
+ opacity: {
+ duration: 0.2,
+ delay: 0.05,
+ },
+ },
+ }}
+ exit={{
+ height: 0,
+ opacity: 0,
+ transition: {
+ height: {
+ duration: 0.25,
+ },
+ opacity: {
+ duration: 0.2,
+ },
+ },
+ }}
+ key={'ul'}
role="group"
className="pl-4"
>
{children.map(node => (
<Node node={node} key={node.id} />
))}
- </ul>
+ </motion.ul>
)}
+ </AnimatePresence>
We set initial={false}
so that the initial animation doesn't occur.
We set a key
prop so that AnimatePresence can track our element as it exits.
We set the height and opacity animations to prevent clipping the text as much as possible. More detail here .
We use MotionConfig
to share the easing between our arrow and our layout animation.
<li
{/* ... */}
>
+ <MotionConfig
+ transition={{
+ ease: [0.164, 0.84, 0.43, 1],
+ duration: 0.25,
+ }}
>
<div
{/* ... */}
>
{children?.length ? (
<Arrow className="h-4 w-4 shrink-0" open={isOpen} />
) : (
<span className="h-4 w-4" />
)}
<span className="text-ellipsis whitespace-nowrap overflow-hidden">
{name}
</span>
</div>
<AnimatePresence initial={false}>
{children?.length && isOpen && (
<motion.ul
initial={{
height: 0,
opacity: 0,
}}
animate={{
height: 'auto',
opacity: 1,
transition: {
height: {
duration: 0.25,
},
opacity: {
duration: 0.2,
delay: 0.05,
},
},
}}
exit={{
height: 0,
opacity: 0,
transition: {
height: {
duration: 0.25,
},
opacity: {
duration: 0.2,
},
},
}}
key={'ul'}
role="group"
className="pl-4 relative"
>
{children.map(node => (
<Node node={node} key={node.id} />
))}
</motion.ul>
)}
</AnimatePresence>
+ </MotionConfig>
</li>
If you press Left
and Down
in quick succession you will notice that our current exit animation allows you to focus the node that is mid exit animation.
We can fix this edge case by updating our getOrderedItems
to stop querying based on aria-expanded
.
function getOrderedItems() {
if (!ref.current) return []
const elementsFromDOM = Array.from(
ref.current.querySelectorAll<HTMLElement>(
- 'data-item'
+ ':where([data-item=true]):not(:where([aria-expanded=false] *))',
),
)
return Array.from(elements.current)
.filter(a => elementsFromDOM.indexOf(a[1]) > -1)
.sort(
(a, b) =>
elementsFromDOM.indexOf(a[1]) - elementsFromDOM.indexOf(b[1]),
)
.map(([id, element]) => ({ id, element }))
}
Here is our finished example animation:
App.tsx treeview.tsx roving-tabindex.tsx data.ts
import { useState } from "react" ;
import { Treeview } from "./treeview"
import { data } from "./data"
export default function App ( ) {
const [ selected , select ] = useState <string | null >( null )
return (
< Treeview .Root
value ={ selected }
onChange ={ select }
className ="w-72 h-full border-[1.5px] border-slate-200 m-4"
>
{ data .map ( node => (
< Treeview .Node node ={ node } key ={ node .id } />
) ) }
</ Treeview .Root >
)
}
show more
See you in the next one!