Kanso UI LogoKanso UI
Components/magic tree
data-display

Magic Tree

A beautiful animated file tree component with sparkles and smooth folder expansion.

treefilefoldernavigationanimated

Preview

Magical Folder
Shiny File.txt
Hidden Gems
Gem.js
Plain File.md

🖲Usage

example-usage.tsx
import { MagicTree } from "@/components/kanso/magic-tree"

export default function MagicTreeDemo() {
  return (
    <div className="p-4 w-64 border rounded-xl">
      <MagicTree />
    </div>
  )
}

Installation

1

Create folder & copy source

Create a folder named kanso inside your project's components directory (i.e. components/kanso/). Copy the source code shown in the next section, and paste it into a file named magic-tree.tsx inside it.

2

Install dependencies

pnpm add framer-motion lucide-react
3

Required helper files

Ensure your project has the following helper files configured:

  • lib/utils

<>Source Code

magic-tree.tsx
'use client';

import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Sparkles, Star, Folder, File, ChevronRight } from 'lucide-react';

// -------- MagicNode Types --------
export type MagicNode = {
  id: string;
  name: string;
  type: 'file' | 'folder';
  children?: MagicNode[];
  sparkle?: boolean;
};

export type MagicTreeProps = {
  data?: MagicNode[];
  onSelect?: (node: MagicNode) => void;
  className?: string;
};

// -------- Default Data --------
const defaultMagicData: MagicNode[] = [
  {
    id: '1',
    name: 'Magical Folder',
    type: 'folder',
    sparkle: true,
    children: [
      { id: '1-1', name: 'Shiny File.txt', type: 'file', sparkle: true },
      {
        id: '1-2',
        name: 'Hidden Gems',
        type: 'folder',
        children: [
          { id: '1-2-1', name: 'Gem.js', type: 'file', sparkle: true },
        ],
      },
    ],
  },
  { id: '2', name: 'Plain File.md', type: 'file' },
];

export function MagicTree({
  data = defaultMagicData,
  onSelect,
  className,
}: MagicTreeProps) {
  const [expanded, setExpanded] = useState<Record<string, boolean>>({
    '1': true,
    '1-2': true,
  });
  const [selected, setSelected] = useState<string | null>(null);

  const toggle = (id: string) => {
    setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
  };

  const renderNodes = (nodes: MagicNode[], level = 0) => {
    return nodes.map((n) => (
      <div key={n.id} className="relative group/node">
        <motion.div
          className={cn(
            'relative flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer select-none text-sm transition-all duration-200 ease-out outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50 border',
            selected === n.id
              ? 'text-indigo-600 dark:text-indigo-400 font-medium bg-indigo-50/80 dark:bg-indigo-500/10 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.4)] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)] border-indigo-100/50 dark:border-indigo-500/20'
              : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 hover:bg-zinc-100/50 dark:hover:bg-zinc-800/30 border-transparent'
          )}
          style={{
            paddingLeft: level * 20 + 8,
            marginBottom: 2,
          }}
          onClick={() => {
            if (n.type === 'folder') toggle(n.id);
            setSelected(n.id);
            onSelect?.(n);
          }}
          tabIndex={0}
          onKeyDown={(e) => {
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault();
              if (n.type === 'folder') toggle(n.id);
              setSelected(n.id);
              onSelect?.(n);
            }
          }}
          whileHover={{ x: n.type === 'file' ? 2 : 0 }}
          whileTap={{ scale: 0.98 }}
        >
          {/* Chevron for Folders */}
          {n.type === 'folder' ? (
            <motion.div
              animate={{ rotate: expanded[n.id] ? 90 : 0 }}
              transition={{ duration: 0.2, ease: 'easeOut' }}
              className="flex items-center justify-center w-4 h-4 shrink-0"
            >
              <ChevronRight
                size={14}
                className={
                  selected === n.id
                    ? 'text-indigo-500'
                    : 'text-zinc-400 group-hover/node:text-zinc-500'
                }
              />
            </motion.div>
          ) : (
            <div className="w-4 h-4 shrink-0" /> // Spacer for files to align with folders
          )}

          {/* Node Icon */}
          <div className="flex items-center justify-center w-5 h-5 shrink-0">
            {selected === n.id ? (
              <Star
                size={14}
                className="text-amber-500 fill-amber-500/30 drop-shadow-[0_0_4px_rgba(245,158,11,0.4)]"
              />
            ) : n.type === 'folder' ? (
              <Folder
                size={14}
                className={cn(
                  'transition-colors',
                  expanded[n.id]
                    ? 'text-blue-500 fill-blue-500/20'
                    : 'text-zinc-400 group-hover/node:text-zinc-500'
                )}
              />
            ) : (
              <File
                size={14}
                className="text-zinc-400 group-hover/node:text-zinc-500 transition-colors"
              />
            )}
          </div>

          <span className="flex-1 truncate">{n.name}</span>

          {n.sparkle && (
            <motion.div
              initial={{ rotate: 0, scale: 0.8 }}
              animate={{ rotate: 360, scale: 1 }}
              transition={{
                rotate: { duration: 8, repeat: Infinity, ease: 'linear' },
                scale: {
                  duration: 2,
                  repeat: Infinity,
                  repeatType: 'reverse',
                  ease: 'easeInOut',
                },
              }}
            >
              <Sparkles
                size={14}
                className={cn(
                  'opacity-50 transition-opacity duration-300 drop-shadow-[0_0_2px_rgba(245,158,11,0.4)]',
                  selected === n.id
                    ? 'text-amber-500 opacity-100'
                    : 'text-amber-400 group-hover/node:opacity-100'
                )}
              />
            </motion.div>
          )}
        </motion.div>

        <AnimatePresence>
          {n.children && n.children.length > 0 && expanded[n.id] && (
            <motion.div
              role="group"
              initial={{ height: 0, opacity: 0, filter: 'blur(4px)' }}
              animate={{ height: 'auto', opacity: 1, filter: 'blur(0px)' }}
              exit={{ height: 0, opacity: 0, filter: 'blur(4px)' }}
              transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
              className="relative overflow-hidden origin-top"
            >
              <div
                className="absolute top-0 bottom-2 w-px bg-gradient-to-b from-zinc-200/80 via-zinc-200/80 to-transparent dark:from-zinc-800/80 dark:via-zinc-800/80 dark:to-transparent"
                style={{ left: level * 20 + 26 }}
              />
              <div className="pt-1">{renderNodes(n.children, level + 1)}</div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    ));
  };

  return (
    <div className={cn('space-y-0 font-sans w-full select-none', className)}>
      {renderNodes(data)}
    </div>
  );
}

Props

Command Palette

Search for a command to run...