Bankk roll

File Viewer

Component 175

About

TypeScript-based React component, inspired by Supabase UI, for navigating file trees and previewing code with Shiki syntax highlighting. Perfect for documentation or dev tools, it offers file selection, code copying, and GitHub integration.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds File Viewer to your project and fits it to your theme.

Code

How File Viewer is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

import ComponentFileViewer, { ApiComponent } from "@/components/ui/file-viewer";

// Sample data for the demo
const sampleComponent: ApiComponent = {
  author: "BankkRoll",
  name: "file-viewer",
  version: "1.0.0",
  files: [
    {
      path: "src/types.ts",
      content: `interface ApiComponent {
  author: string;
  name: string;
  version: string;
  files: Array<{
    path: string;
    content?: string;
  }>;
}

interface TreeViewElement {
  id: string;
  name: string;
  isSelectable?: boolean;
  children?: TreeViewElement[];
}

interface TreeContextProps {
  selectedId: string | undefined;
  expandedItems: string[] | undefined;
  handleExpand: (id: string) => void;
  selectItem: (id: string) => void;
  setExpandedItems?: React.Dispatch<React.SetStateAction<string[] | undefined>>;
  indicator: boolean;
  openIcon?: React.ReactNode;
  closeIcon?: React.ReactNode;
  direction: "rtl" | "ltr";
}`,
    },
    {
      path: "src/components/shiki-viewer.tsx",
      content: `"use client";

import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { createHighlighter } from "shiki";
import { cn } from "utils";

interface ShikiViewerProps {
  code: string;
  lang?: string;
  showLineNumbers?: boolean;
  className?: string;
}

export function ShikiViewer({
  code,
  lang = "tsx",
  showLineNumbers = true,
  className,
}: ShikiViewerProps) {
  const [html, setHtml] = useState<string>("");
  const [isLoading, setIsLoading] = useState(true);
  const { resolvedTheme } = useTheme();

  useEffect(() => {
    let mounted = true;
    async function highlight() {
      try {
        setIsLoading(true);
        const shikiTheme =
          resolvedTheme === "dark" ? "github-dark" : "github-light";
        const highlighter = await createHighlighter({
          langs: [
            "tsx",
            "typescript",
            "javascript",
            "jsx",
            "json",
            "css",
            "scss",
            "html",
            "markdown",
          ],
          themes: [shikiTheme],
        });
        const highlightedHtml = highlighter.codeToHtml(code, {
          lang: lang === "tsx" ? "typescript" : lang,
          theme: shikiTheme,
        });
        if (mounted) {
          setHtml(highlightedHtml);
          setIsLoading(false);
        }
      } catch (error) {
        if (mounted) {
          setHtml(\`<pre><code>\${code}</code></pre>\`);
          setIsLoading(false);
        }
      }
    }
    highlight();
    return () => {
      mounted = false;
    };
  }, [code, lang, resolvedTheme]);

  const addLineNumbers = (html: string) => {
    if (!showLineNumbers) return html;
    const lines = code.split("\\n");
    const lineNumbers = lines.map((_, i) => \`<span>\${i + 1}</span>\`).join("");
    return html.replace(
      /<pre[^>]*>([\\s\\S]*)<\\/pre>/,
      \`<pre class="line-numbers"><span class="line-numbers-rows">\${lineNumbers}</span>$1</pre>\`
    );
  };

  return (
    <>
      <style>{\`
        .shiki-viewer { border-radius: 0.5rem; overflow: hidden; border: 1px solid hsl(var(--border)); }
        .shiki-viewer pre { margin: 0; padding: 1rem; overflow-x: auto; background: transparent; font-size: 0.875rem; line-height: 1.5; white-space: pre; }
        .shiki-viewer code { background: transparent; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; line-height: inherit; white-space: pre; }
        .shiki-viewer .line-numbers { display: flex; }
        .shiki-viewer .line-numbers .line-numbers-rows { display: flex; flex-direction: column; padding-right: 0.2rem; margin-right: 0.2rem; border-right: 1px solid hsl(var(--border)); text-align: right; color: hsl(var(--muted-foreground)); font-size: 0.75rem; user-select: none; }
        .shiki-viewer .line-numbers .line-numbers-rows > span { display: block; min-width: 2rem; }
      \`}</style>
      <div className={cn("shiki-viewer", className)}>
        {isLoading ? (
          <div className="flex items-center justify-center p-8">
            <div className="animate-pulse text-muted-foreground">
              Loading code...
            </div>
          </div>
        ) : (
          <div dangerouslySetInnerHTML={{ __html: addLineNumbers(html) }} />
        )}
      </div>
    </>
  );
}`,
    },
    {
      path: "src/components/file-header.tsx",
      content: `import { Badge } from "ui/badge";
import { Button } from "ui/button";
import { Check, Copy, ExternalLink } from "lucide-react";
import type { ApiComponent } from "../types";

interface FileHeaderProps {
  file: { path: string; content?: string };
  component: ApiComponent;
  onCopy: () => void;
  copied: boolean;
}

export function FileHeader({
  file,
  component,
  onCopy,
  copied,
}: FileHeaderProps) {
  const getFileType = (filePath: string) => {
    if (filePath.endsWith(".tsx")) return "TSX";
    if (filePath.endsWith(".ts")) return "TS";
    if (filePath.endsWith(".js")) return "JS";
    if (filePath.endsWith(".jsx")) return "JSX";
    if (filePath.endsWith(".md")) return "MD";
    if (filePath.endsWith(".css")) return "CSS";
    if (filePath.endsWith(".json")) return "JSON";
    return "TXT";
  };

  return (
    <div className="flex items-center justify-between px-3 py-1.5 border-b bg-muted/30">
      <div className="flex items-center gap-2 min-w-0">
        <Badge variant="outline" className="text-xs">
          {getFileType(file.path)}
        </Badge>
        <span className="text-xs text-muted-foreground truncate">
          {file.path}
        </span>
      </div>
      <div className="flex gap-1">
        <Button
          variant="ghost"
          size="sm"
          onClick={onCopy}
          className="cursor-pointer h-8 w-8 p-0"
          title="Copy file content"
        >
          {copied ? (
            <Check className="h-3 w-3" />
          ) : (
            <Copy className="h-3 w-3" />
          )}
        </Button>
        <Button
          variant="ghost"
          size="sm"
          asChild
          className="h-8 w-8 p-0"
          title="View on GitHub"
        >
          <a
            href="https://21st.dev/bankkroll/file-viewer/default"
            target="_blank"
            rel="noopener noreferrer"
          >
            <ExternalLink className="h-3 w-3" />
          </a>
        </Button>
      </div>
    </div>
  );
}`,
    },
    {
      path: "src/components/file-tree.tsx",
      content: `"use client";

import * as AccordionPrimitive from "@radix-ui/react-accordion";
import {
  FileCode,
  FileIcon,
  FolderIcon,
  FolderOpenIcon,
} from "lucide-react";
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";
import { ScrollArea } from "ui/scroll-area";
import { cn } from "utils";
import type { TreeViewElement, TreeContextProps } from "../types";

// Tree Context
const TreeContext = createContext<TreeContextProps | null>(null);
const useTree = () => {
  const context = useContext(TreeContext);
  if (!context) throw new Error("useTree must be used within a TreeProvider");
  return context;
};

// Tree Components
function TreeIndicator({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  const { direction } = useTree();
  return (
    <div
      className={cn(
        "absolute left-1.5 h-full w-px rounded-md bg-muted py-3 transition-colors hover:bg-slate-300 rtl:right-1.5",
        className
      )}
      {...props}
    />
  );
}

function Folder({
  element,
  value,
  isSelectable = true,
  isSelect,
  children,
  className,
}: {
  element: string;
  value: string;
  isSelectable?: boolean;
  isSelect?: boolean;
  children: React.ReactNode;
  className?: string;
}) {
  const {
    direction,
    handleExpand,
    expandedItems,
    indicator,
    openIcon,
    closeIcon,
  } = useTree();

  return (
    <AccordionPrimitive.Item
      value={value}
      className="relative h-full overflow-hidden"
    >
      <AccordionPrimitive.Trigger
        className={cn(
          "flex items-center gap-1 rounded-md text-sm px-2 py-1 hover:bg-accent hover:text-accent-foreground cursor-pointer",
          isSelect && isSelectable && "bg-muted",
          !isSelectable && "opacity-50 cursor-not-allowed",
          className
        )}
        disabled={!isSelectable}
        onClick={() => handleExpand(value)}
      >
        {expandedItems?.includes(value)
          ? openIcon ?? <FolderOpenIcon className="h-4 w-4" />
          : closeIcon ?? <FolderIcon className="h-4 w-4" />}
        <span className="truncate">{element}</span>
      </AccordionPrimitive.Trigger>
      <AccordionPrimitive.Content className="relative h-full overflow-hidden text-sm">
        {indicator && <TreeIndicator />}
        <AccordionPrimitive.Root
          type="multiple"
          className={cn(
            "ml-5 flex flex-col gap-1 py-1",
            direction === "rtl" && "mr-5"
          )}
          value={expandedItems}
        >
          {children}
        </AccordionPrimitive.Root>
      </AccordionPrimitive.Content>
    </AccordionPrimitive.Item>
  );
}

function File({
  value,
  isSelectable = true,
  isSelect,
  fileIcon,
  children,
  className,
  onClick,
}: {
  value: string;
  isSelectable?: boolean;
  isSelect?: boolean;
  fileIcon?: React.ReactNode;
  children: React.ReactNode;
  className?: string;
  onClick?: () => void;
}) {
  const { selectedId, selectItem } = useTree();
  const isSelected = isSelect ?? selectedId === value;

  return (
    <button
      disabled={!isSelectable}
      className={cn(
        "flex w-fit items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors cursor-pointer",
        isSelected && isSelectable && "bg-muted",
        !isSelectable
          ? "opacity-50 cursor-not-allowed"
          : "hover:bg-accent hover:text-accent-foreground",
        className
      )}
      onClick={() => {
        selectItem(value);
        onClick?.();
      }}
    >
      {fileIcon ?? <FileIcon className="h-4 w-4" />}
      <span className="truncate">{children}</span>
    </button>
  );
}

function Tree({
  elements,
  initialSelectedId,
  initialExpandedItems,
  children,
  className,
  indicator = true,
  openIcon,
  closeIcon,
  dir = "ltr",
}: {
  elements?: TreeViewElement[];
  initialSelectedId?: string;
  initialExpandedItems?: string[];
  children: React.ReactNode;
  className?: string;
  indicator?: boolean;
  openIcon?: React.ReactNode;
  closeIcon?: React.ReactNode;
  dir?: "rtl" | "ltr";
}) {
  const [selectedId, setSelectedId] = useState<string | undefined>(
    initialSelectedId
  );
  const [expandedItems, setExpandedItems] = useState<string[] | undefined>(
    initialExpandedItems
  );

  const getAllExpandableItems = useCallback(
    (elements?: TreeViewElement[]): string[] => {
      const expandableItems: string[] = [];
      const traverse = (items: TreeViewElement[]) => {
        items.forEach((item) => {
          if (item.children?.length) {
            expandableItems.push(item.id);
            traverse(item.children);
          }
        });
      };
      if (elements) traverse(elements);
      return expandableItems;
    },
    []
  );

  const selectItem = useCallback((id: string) => setSelectedId(id), []);
  const handleExpand = useCallback((id: string) => {
    setExpandedItems((prev) => {
      if (prev?.includes(id)) return prev.filter((item) => item !== id);
      return [...(prev ?? []), id];
    });
  }, []);

  useEffect(() => {
    if (elements) setExpandedItems(getAllExpandableItems(elements));
  }, [elements, getAllExpandableItems]);

  return (
    <TreeContext.Provider
      value={{
        selectedId,
        expandedItems,
        handleExpand,
        selectItem,
        setExpandedItems,
        indicator,
        openIcon,
        closeIcon,
        direction: dir,
      }}
    >
      <div className={cn("size-full", className)}>
        <div className="relative h-full px-2">
          <AccordionPrimitive.Root
            type="multiple"
            value={expandedItems}
            className="flex flex-col gap-1"
          >
            {children}
          </AccordionPrimitive.Root>
        </div>
      </div>
    </TreeContext.Provider>
  );
}

function TreeItem({
  item,
  selectedFile,
  onFileSelect,
}: {
  item: TreeViewElement;
  selectedFile?: string;
  onFileSelect: (file: string) => void;
}) {
  if (item.children?.length) {
    return (
      <Folder
        key={item.id}
        element={item.name}
        value={item.id}
        className="truncate"
      >
        {item.children.map((child) => (
          <TreeItem
            key={child.id}
            item={child}
            selectedFile={selectedFile}
            onFileSelect={onFileSelect}
          />
        ))}
      </Folder>
    );
  }

  return (
    <File
      key={item.id}
      value={item.id}
      onClick={() => onFileSelect(item.id)}
      isSelectable={true}
      isSelect={selectedFile === item.id}
      className="truncate whitespace-nowrap"
    >
      {item.name}
    </File>
  );
}

export function FileTree({
  tree,
  selectedFile,
  onFileSelect,
}: {
  tree: TreeViewElement[];
  selectedFile?: string;
  onFileSelect: (file: string) => void;
}) {
  const allExpandableItems = useMemo(() => {
    const expandableItems: string[] = [];
    const traverse = (elements: TreeViewElement[]) => {
      elements.forEach((element) => {
        if (element.children?.length) {
          expandableItems.push(element.id);
          traverse(element.children);
        }
      });
    };
    traverse(tree);
    return expandableItems;
  }, [tree]);

  return (
    <div className="w-full h-full border-r">
      <div className="p-3 border-b flex items-center gap-2">
        <FileCode className="h-4 w-4" />
        <span className="text-sm font-medium">Files</span>
      </div>
      <ScrollArea className="h-96 lg:h-[calc(100vh-300px)]">
        <div className="p-2">
          <Tree
            elements={tree}
            initialExpandedItems={allExpandableItems}
            initialSelectedId={selectedFile}
            indicator
          >
            {tree.map((item) => (
              <TreeItem
                key={item.id}
                item={item}
                selectedFile={selectedFile}
                onFileSelect={onFileSelect}
              />
            ))}
          </Tree>
        </div>
      </ScrollArea>
    </div>
  );
}`,
    },
    {
      path: "src/components/file-viewer.tsx",
      content: `"use client";

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "ui/resizable";
import { ScrollArea } from "ui/scroll-area";
import { useMemo, useState, useEffect } from "react";
import { toast } from "sonner";
import type { ApiComponent, TreeViewElement } from "../types";
import { FileTree } from "./FileTree";
import { FileHeader } from "./FileHeader";
import { ShikiViewer } from "./ShikiViewer";

export default function ComponentFileViewer({
  component,
}: {
  component: ApiComponent;
}) {
  const [selectedFile, setSelectedFile] = useState<string | undefined>(
    undefined
  );
  const [copied, setCopied] = useState(false);
  const files = component.files.filter((f) => f.content);

  // Build tree structure
  const tree = useMemo(() => {
    const root: Record<string, any> = {};
    for (const file of files) {
      const parts = file.path.split("/");
      let current = root;
      for (let i = 0; i < parts.length; i++) {
        const part = parts[i];
        if (!current[part]) {
          current[part] =
            i === parts.length - 1
              ? { ...file, id: file.path, name: part, isSelectable: true }
              : {
                  id: parts.slice(0, i + 1).join("/"),
                  name: part,
                  children: {},
                  isSelectable: false,
                };
        }
        current = current[part].children || current[part];
      }
    }
    const toArray = (obj: Record<string, any>): TreeViewElement[] =>
      Object.values(obj).map((item: any) =>
        item.children ? { ...item, children: toArray(item.children) } : item
      );
    return toArray(root);
  }, [files]);

  const selected = files.find((f) => f.path === selectedFile) || files[0];

  useEffect(() => {
    if (!selectedFile && files.length > 0) {
      setSelectedFile(files[0].path);
    }
  }, [files, selectedFile]);

  const handleCopy = () => {
    if (selected?.content) {
      navigator.clipboard.writeText(selected.content);
      setCopied(true);
      toast.success("File content copied");
      setTimeout(() => setCopied(false), 2000);
    }
  };

  return (
    <ResizablePanelGroup
      direction="horizontal"
      className="min-h-[600px] rounded-lg border overflow-hidden"
    >
      <ResizablePanel defaultSize={25} minSize={20} maxSize={40}>
        <FileTree
          tree={tree}
          selectedFile={selectedFile}
          onFileSelect={setSelectedFile}
        />
      </ResizablePanel>
      <ResizableHandle withHandle />
      <ResizablePanel defaultSize={75} minSize={40}>
        {selected && (
          <div className="h-full flex flex-col">
            <FileHeader
              file={selected}
              component={component}
              onCopy={handleCopy}
              copied={copied}
            />
            <div className="flex-1 overflow-hidden">
              <ScrollArea className="w-full h-[calc(100vh-20rem)]">
                <ShikiViewer
                  code={selected.content || ""}
                  lang={selected.path.split(".").pop() || "txt"}
                  className="min-h-full"
                />
              </ScrollArea>
            </div>
          </div>
        )}
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}`,
    },
    {
      path: "src/utils.ts",
      content: `export function cn(...classes: (string | undefined | null | false)[]): string {
  return classes.filter(Boolean).join(" ");
}`,
    },
    {
      path: "README.md",
      content: `# Component File Viewer

A React component for viewing and navigating through component files with syntax highlighting.

## Features

- **File Tree Navigation**: Browse through component files with an expandable tree structure
- **Syntax Highlighting**: Powered by Shiki for beautiful code highlighting
- **Copy to Clipboard**: Copy file contents with a single click
- **Resizable Panels**: Adjust the file tree and code viewer sizes
- **Theme Support**: Automatically adapts to light/dark themes
- **Line Numbers**: Optional line numbering for better code readability

## Usage

\`\`\`tsx
import ComponentFileViewer, { ApiComponent } from "./components/ComponentFileViewer";

const component: ApiComponent = {
  author: "YourName",
  name: "my-component",
  version: "1.0.0",
  files: [
    {
      path: "src/index.tsx",
      content: "// Your component code here"
    }
  ]
};

function App() {
  return <ComponentFileViewer component={component} />;
}
\`\`\`

## File Structure

The component is organized into several modules:

- \`types.ts\` - TypeScript interfaces and types
- \`components/ShikiViewer.tsx\` - Code syntax highlighting component
- \`components/FileHeader.tsx\` - File header with actions
- \`components/FileTree.tsx\` - File tree navigation component
- \`components/ComponentFileViewer.tsx\` - Main component
- \`utils.ts\` - Utility functions

## Dependencies

- React
- Radix UI (Accordion, Resizable)
- Lucide React (Icons)
- Shiki (Syntax highlighting)
- Next Themes (Theme support)
- Sonner (Toast notifications)

## License

MIT`,
    },
    {
      path: "package.json",
      content: `{
  "name": "file-viewer",
  "version": "1.0.0",
  "description": "A React component for viewing and navigating through component files",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "test": "jest"
  },
  "dependencies": {
    "@radix-ui/react-accordion": "^1.1.2",
    "@radix-ui/react-resizable": "^1.0.0",
    "@radix-ui/react-scroll-area": "^1.0.5",
    "lucide-react": "^0.294.0",
    "next-themes": "^0.2.1",
    "react": "^18.2.0",
    "shiki": "^1.1.7",
    "sonner": "^1.2.4"
  },
  "devDependencies": {
    "@types/react": "^18.2.45",
    "typescript": "^5.3.3"
  },
  "peerDependencies": {
    "react": ">=18.0.0"
  }
}`,
    },
  ],
};

export default function ComponentFileViewerDemo() {
  return (
    <div className="p-4 w-full max-w-5xl mx-auto">
      <ComponentFileViewer component={sampleComponent} />
    </div>
  );
};

Dependencies

More by Bankk roll

View all
  • Animated Testimonials

    Animated Testimonials

    1.1K
  • Commerce Hero

    Commerce Hero

    650
  • Acme Hero

    Acme Hero

    437
  • Myna Hero

    Myna Hero

    383
  • Single Pricing Card

    Single Pricing Card

    305
  • Pricing Card

    Pricing Card

    287
  • Code Editor Sheet

    Code Editor Sheet

    142
  • Auth Form

    Auth Form

    90