React treeview component (pt. 3)

Apr 10, 2023

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

  1. Animate the arrow indicating the collapsed state of our Node
  2. Animate the "enter" and "exit" animations as Nodes get added and removed from the DOM
  3. 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>
  1. We set initial={false} so that the initial animation doesn't occur.
  2. We set a key prop so that AnimatePresence can track our element as it exits.
  3. 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!

Subscribe to the newsletter

A monthly no filler update.

Contact me at