All patterns

Tree Node Expand

A folder's chevron turns as its children unfold with a small stagger.

navigationminimalcalminteraction · finite · intermediate · ~0.3s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

370 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Tree Node Expand
 *
 * A folder tree that unfolds. The chevron turns to say which way the
 * folder is facing, the space for the children opens, and the children
 * themselves arrive a beat apart so the eye reads depth rather than a
 * block of new text appearing at once.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the tree reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `defaultOpen`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TreeNodeExpandProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Ids of the folders open on first render. */
  defaultOpen?: string[];
  /** Notified with the folder id and its new state. */
  onToggle?: (id: string, open: boolean) => void;
};

type VariantConfig = {
  /** Seconds the space for the children takes to open. */
  unfold: number;
  /** Seconds between one child and the next. */
  stagger: number;
  /** px each child rises from as it arrives. */
  lift: number;
  chevron: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: a tree is read, not watched. The height tween is short
// and eased because it is a genuine size change, the chevron spring sits
// above a 0.8 damping ratio (ζ = damping / 2√stiffness) so a 10px glyph
// never wobbles, and the stagger stays small enough that a folder of ten
// items still finishes in one glance.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost no cascade — for a file explorer used all day.
  subtle: {
    unfold: 0.16,
    stagger: 0.012,
    lift: 2,
    chevron: { type: "spring", stiffness: 590, damping: 45 },
  },
  // A readable beat between children. The all-purpose setting.
  default: {
    unfold: 0.26,
    stagger: 0.032,
    lift: 4,
    chevron: { type: "spring", stiffness: 420, damping: 36 },
  },
  // A longer cascade for shallow trees where the unfolding is the point.
  playful: {
    unfold: 0.32,
    stagger: 0.058,
    lift: 7,
    chevron: { type: "spring", stiffness: 360, damping: 31 },
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Eased both ends: a size change that starts and stops abruptly reads
 *  as a jump cut, and this one is short enough to notice either way. */
const UNFOLD_EASE: [number, number, number, number] = [0.32, 0.72, 0.35, 1];

type TreeNode = {
  id: string;
  name: string;
  meta?: string;
  children?: TreeNode[];
};

const TREE: TreeNode[] = [
  {
    id: "marketing",
    name: "Marketing",
    meta: "3 items",
    children: [
      { id: "brief", name: "Campaign brief", meta: "Doc" },
      { id: "checklist", name: "Launch checklist", meta: "Doc" },
      {
        id: "assets",
        name: "Assets",
        meta: "2 items",
        children: [
          { id: "logo", name: "Logo pack", meta: "Archive" },
          { id: "shots", name: "Product shots", meta: "Album" },
        ],
      },
    ],
  },
  {
    id: "finance",
    name: "Finance",
    meta: "2 items",
    children: [
      { id: "forecast", name: "Q3 forecast", meta: "Sheet" },
      { id: "invoices", name: "Invoices", meta: "Folder" },
    ],
  },
  { id: "handbook", name: "Team handbook", meta: "Doc" },
];

export default function TreeNodeExpand({
  variant = "default",
  defaultOpen = ["marketing"],
  onToggle,
}: TreeNodeExpandProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [open, setOpen] = useState<string[]>(defaultOpen);

  const toggle = (id: string) => {
    const isOpen = open.includes(id);
    setOpen(isOpen ? open.filter((entry) => entry !== id) : [...open, id]);
    onToggle?.(id, !isOpen);
  };

  return (
    <div
      style={{
        width: 300,
        height: 292,
        padding: "12px 10px",
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 12px 32px rgba(0,0,0,0.14)",
        overflowY: "auto",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          padding: "0 6px 8px",
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>Workspace</span>
        <span style={{ fontSize: 11, opacity: 0.45 }}>Shared with 6</span>
      </div>

      <div role="tree" aria-label="Workspace folders">
        {TREE.map((node) => (
          <Branch
            key={node.id}
            node={node}
            depth={0}
            open={open}
            onToggle={toggle}
            cfg={cfg}
            reduceMotion={Boolean(reduceMotion)}
          />
        ))}
      </div>
    </div>
  );
}

function Branch({
  node,
  depth,
  open,
  onToggle,
  cfg,
  reduceMotion,
}: {
  node: TreeNode;
  depth: number;
  open: string[];
  onToggle: (id: string) => void;
  cfg: VariantConfig;
  reduceMotion: boolean;
}) {
  const isFolder = Boolean(node.children?.length);
  const isOpen = isFolder && open.includes(node.id);

  // Variants, not per-element delays: the parent hands each child its
  // turn, so the cascade keeps working however deep the tree goes.
  const rowVariants = {
    closed: { opacity: 0, y: reduceMotion ? 0 : -cfg.lift },
    open: { opacity: 1, y: 0 },
  };

  return (
    <motion.div
      role="treeitem"
      aria-expanded={isFolder ? isOpen : undefined}
      aria-level={depth + 1}
      variants={rowVariants}
      transition={{ duration: reduceMotion ? 0 : 0.22, ease: "easeOut" }}
    >
      <button
        type="button"
        onClick={() => isFolder && onToggle(node.id)}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          padding: "6px 8px",
          marginLeft: depth * 16,
          width: `calc(100% - ${depth * 16}px)`,
          borderRadius: 9,
          border: 0,
          background: "transparent",
          color: "inherit",
          fontFamily: "inherit",
          fontSize: 12.5,
          textAlign: "left",
          cursor: isFolder ? "pointer" : "default",
        }}
      >
        <span
          aria-hidden
          style={{
            display: "grid",
            placeItems: "center",
            width: 14,
            height: 14,
            flexShrink: 0,
            opacity: isFolder ? 0.7 : 0,
          }}
        >
          {/* The chevron turns rather than swapping glyphs: one object
              changing direction, which is the cheapest possible way to
              say "this is the same folder, facing the other way". */}
          <motion.svg
            width="11"
            height="11"
            viewBox="0 0 20 20"
            fill="none"
            stroke="currentColor"
            strokeWidth="2.2"
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={false}
            animate={{ rotate: isOpen ? 90 : 0 }}
            transition={reduceMotion ? { duration: 0 } : cfg.chevron}
          >
            <path d="M7.5 4.5 13 10l-5.5 5.5" />
          </motion.svg>
        </span>

        <span aria-hidden style={{ display: "grid", placeItems: "center", flexShrink: 0 }}>
          {isFolder ? (
            <svg
              width="15"
              height="15"
              viewBox="0 0 20 20"
              fill="none"
              stroke={ACCENT}
              strokeWidth="1.5"
              strokeLinejoin="round"
              aria-hidden
            >
              <path d="M2.5 5.5h5l1.6 2h8.4v7H2.5z" />
            </svg>
          ) : (
            <svg
              width="15"
              height="15"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.4"
              strokeLinejoin="round"
              opacity={0.55}
              aria-hidden
            >
              <path d="M5 2.8h6.5L15 6.3v10.9H5z" />
              <path d="M11.2 2.9v3.6h3.6" />
            </svg>
          )}
        </span>

        <span
          style={{
            flex: 1,
            minWidth: 0,
            fontWeight: isFolder ? 600 : 500,
            whiteSpace: "nowrap",
            overflow: "hidden",
            textOverflow: "ellipsis",
          }}
        >
          {node.name}
        </span>
        {node.meta && (
          <span style={{ fontSize: 11, opacity: 0.42, flexShrink: 0 }}>
            {node.meta}
          </span>
        )}
      </button>

      <AnimatePresence initial={false}>
        {isOpen && (
          <motion.div
            key="children"
            // Height is animated because the motion genuinely is a size
            // change — the space for the children has to exist before
            // they can be in it. Kept short, eased both ends, and paired
            // with overflow hidden so nothing spills while it opens.
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{
              height: {
                duration: reduceMotion ? 0 : cfg.unfold,
                ease: UNFOLD_EASE,
              },
              opacity: { duration: reduceMotion ? 0 : cfg.unfold * 0.6 },
            }}
            style={{ overflow: "hidden" }}
          >
            <motion.div
              role="group"
              initial="closed"
              animate="open"
              exit="closed"
              variants={{
                open: {
                  transition: {
                    staggerChildren: reduceMotion ? 0 : cfg.stagger,
                    delayChildren: reduceMotion ? 0 : cfg.stagger,
                  },
                },
                // Closing runs from the bottom up, so the folder reads as
                // folding back into its own row.
                closed: {
                  transition: {
                    staggerChildren: reduceMotion ? 0 : cfg.stagger * 0.5,
                    staggerDirection: -1,
                  },
                },
              }}
            >
              {node.children?.map((child) => (
                <Branch
                  key={child.id}
                  node={child}
                  depth={depth + 1}
                  open={open}
                  onToggle={onToggle}
                  cfg={cfg}
                  reduceMotion={reduceMotion}
                />
              ))}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

About this pattern

File explorers, nested folders and any hierarchy that opens in place. Three things happen in sequence and each is deliberately small: the chevron turns to say the folder is now facing the other way, the space for the children opens as a short eased height change because the motion genuinely is a size change, and the children arrive a beat apart so depth is read rather than counted. The cascade comes from parent-to-child variants instead of hand-written delays, which is why it keeps working however deep the tree goes — and why closing runs bottom-up, so a folder appears to fold back into its own row.

File explorer sidebarNested folder browserCategory pickerOrg hierarchy

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Changes
    Commits
    Checks
    Files
    Conversation
    ChangesNew
    Brand refresh.figPriya Raman · 2.4 MB
    Q3 planning.pdfMarcus Bell · 840 KB
    Supplier contract.docxDana Whitfield · 96 KB
    Photography brief.mdNils Bergström · 12 KB
    Invoice 4821.pdfBilling · 64 KB
    Code review

    Folders turn their chevron and reveal nested contents in place.

Related patterns