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.
Arrow animation
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>
)
}
Layout animation
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.
MotionConfig
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>
Preventing focus from entering exiting Node
's
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:
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> ) }
See you in the next one!