All components

Editor Tabs

VS Code-style editor tab bar with horizontal scroll, active accent, closable tabs, dirty indicators, and composable primitives.

Default
Basic editor tabs with panel content

Project overview and recent activity.

1"use client";
2
3import { useState } from "react";
4import {
5 EditorTab,
6 EditorTabs,
7 EditorTabsList,
8 EditorTabsPanel,
9} from "@/registry/ui";
10
11const defaultPanels: Record<string, string> = {
12 overview: "Project overview and recent activity.",
13 analytics: "Traffic, conversion, and retention metrics.",
14 settings: "Workspace defaults and notification preferences.",
15};
16
17export function EditorTabsDefaultExample() {
18 const [active, setActive] = useState("overview");
19
20 return (
21 <div className="mx-auto w-full max-w-2xl overflow-hidden rounded-lg border border-border">
22 <EditorTabs value={active} onValueChange={setActive} variant="bordered">
23 <EditorTabsList>
24 <EditorTab value="overview">Overview</EditorTab>
25 <EditorTab value="analytics">Analytics</EditorTab>
26 <EditorTab value="settings">Settings</EditorTab>
27 </EditorTabsList>
28 <EditorTabsPanel className="p-4">
29 <p className="text-sm text-muted-foreground">
30 {defaultPanels[active]}
31 </p>
32 </EditorTabsPanel>
33 </EditorTabs>
34 </div>
35 );
36}
Dirty & pinned
Tabs with icons, dirty state, and pinned tab

Active tab: layout.tsx

1"use client";
2
3import { useState } from "react";
4import { FileCode, FileJson, FileText } from "lucide-react";
5import {
6 EditorTab,
7 EditorTabs,
8 EditorTabsList,
9 EditorTabsPanel,
10} from "@/registry/ui";
11
12export function EditorTabsDirtyExample() {
13 const [active, setActive] = useState("layout.tsx");
14
15 return (
16 <div className="mx-auto w-full max-w-2xl overflow-hidden rounded-lg border border-border">
17 <EditorTabs value={active} onValueChange={setActive}>
18 <EditorTabsList>
19 <EditorTab
20 value="layout.tsx"
21 icon={<FileCode className="text-blue-500" />}
22 dirty
23 >
24 layout.tsx
25 </EditorTab>
26 <EditorTab
27 value="page.tsx"
28 icon={<FileCode className="text-blue-500" />}
29 >
30 page.tsx
31 </EditorTab>
32 <EditorTab
33 value="package.json"
34 icon={<FileJson className="text-yellow-600" />}
35 pinned
36 >
37 package.json
38 </EditorTab>
39 <EditorTab
40 value="README.md"
41 icon={<FileText className="text-muted-foreground" />}
42 >
43 README.md
44 </EditorTab>
45 </EditorTabsList>
46 <EditorTabsPanel className="p-4">
47 <p className="font-mono text-sm text-muted-foreground">
48 Active tab: {active}
49 </p>
50 </EditorTabsPanel>
51 </EditorTabs>
52 </div>
53 );
54}
Closable
Close tabs individually with middle-click support

Active tab: utils.ts

1"use client";
2
3import { FileCode } from "lucide-react";
4import {
5 EditorTab,
6 EditorTabs,
7 EditorTabsList,
8 EditorTabsPanel,
9 useEditorTabs,
10} from "@/registry/ui";
11
12type DemoTab = {
13 id: string;
14 label: string;
15};
16
17const initialClosableTabs: DemoTab[] = [
18 { id: "utils.ts", label: "utils.ts" },
19 { id: "constants.ts", label: "constants.ts" },
20 { id: "hooks.ts", label: "hooks.ts" },
21];
22
23export function EditorTabsClosableExample() {
24 const { tabs, activeId, setActiveId, closeTab } = useEditorTabs<DemoTab>({
25 initialTabs: initialClosableTabs,
26 initialActiveId: "utils.ts",
27 });
28
29 return (
30 <div className="mx-auto w-full max-w-2xl overflow-hidden rounded-lg border border-border">
31 <EditorTabs value={activeId} onValueChange={setActiveId} variant="ghost">
32 <EditorTabsList>
33 {tabs.map((tab) => (
34 <EditorTab
35 key={tab.id}
36 value={tab.id}
37 icon={<FileCode className="text-blue-500" />}
38 onClose={closeTab}
39 >
40 {tab.label}
41 </EditorTab>
42 ))}
43 </EditorTabsList>
44 <EditorTabsPanel className="p-4">
45 {tabs.length === 0 ? (
46 <p className="text-sm text-muted-foreground">
47 All tabs closed. Re-open files from the tree in the workspace
48 demo.
49 </p>
50 ) : (
51 <p className="font-mono text-sm text-muted-foreground">
52 Active tab: {activeId}
53 </p>
54 )}
55 </EditorTabsPanel>
56 </EditorTabs>
57 </div>
58 );
59}
File explorer workspace
File tree, editor tabs, and file viewer combined
Explorer
src
app
layout.tsx
page.tsx
globals.css
api
auth
route.ts
users
route.ts
components
ui
button.tsx
card.tsx
file-tree.tsx
header.tsx
footer.tsx
lib
utils.ts
constants.ts
hooks
use-auth.ts
use-theme.ts
public
favicon.ico
logo.svg
images
hero.png
avatar.jpg
package.json
tsconfig.json
next.config.mjs
tailwind.config.ts
.env.local
.gitignore
README.md

Select a file to view its contents

1"use client";
2
3import {
4 FileTree,
5 FileTreeNode,
6 FileExplorer,
7 FileExplorerContent,
8 FileViewer,
9 FileContent,
10 EditorTabs,
11 EditorTabsList,
12 EditorTab,
13 EditorTabsPanel,
14 useEditorTabs,
15 EditorSidebar,
16 EditorSidebarRail,
17 EditorSidebarTrigger,
18 EditorSidebarContent,
19 EditorSidebarPanel,
20 useEditorSidebar,
21} from "@/registry/ui";
22import { useState, useMemo, type ReactNode } from "react";
23import {
24 File,
25 FileCode,
26 FileJson,
27 FileText,
28 Image as ImageIcon,
29 Cog,
30 Package,
31 Files,
32 Search,
33 Blocks,
34 PanelLeft,
35 PanelLeftClose,
36} from "lucide-react";
37import { Button } from "@/components/ui/button";
38import { cn } from "@/lib/utils";
39
40const fileContents: Record<string, string> = {
41 "src/app/layout.tsx": `import type { Metadata } from "next"
42import { Inter } from "next/font/google"
43import "./globals.css"
44
45const inter = Inter({ subsets: ["latin"] })
46
47export const metadata: Metadata = {
48 title: "My App",
49 description: "A Next.js application",
50}
51
52export default function RootLayout({
53 children,
54}: {
55 children: React.ReactNode
56}) {
57 return (
58 <html lang="en">
59 <body className={inter.className}>{children}</body>
60 </html>
61 )
62}`,
63 "src/app/page.tsx": `export default function Home() {
64 return (
65 <main className="flex min-h-screen flex-col items-center justify-center p-24">
66 <h1 className="text-4xl font-bold">Welcome to Next.js</h1>
67 <p className="mt-4 text-lg text-muted-foreground">
68 Get started by editing app/page.tsx
69 </p>
70 </main>
71 )
72 }`,
73 "src/app/globals.css": `@tailwind base;
74 @tailwind components;
75 @tailwind utilities;
76
77 :root {
78 --foreground-rgb: 0, 0, 0;
79 --background-start-rgb: 214, 219, 220;
80 --background-end-rgb: 255, 255, 255;
81 }
82
83 @media (prefers-color-scheme: dark) {
84 :root {
85 --foreground-rgb: 255, 255, 255;
86 --background-start-rgb: 0, 0, 0;
87 --background-end-rgb: 0, 0, 0;
88 }
89 }`,
90 "src/app/api/auth/route.ts": `import { NextResponse } from "next/server"
91
92 export async function POST(request: Request) {
93 const body = await request.json()
94 const { email, password } = body
95
96 // Validate credentials
97 if (!email || !password) {
98 return NextResponse.json(
99 { error: "Missing credentials" },
100 { status: 400 }
101 )
102 }
103
104 // TODO: Implement actual authentication
105 return NextResponse.json({ success: true })
106 }`,
107 "src/app/api/users/route.ts": `import { NextResponse } from "next/server"
108
109 const users = [
110 { id: 1, name: "Alice", email: "alice@example.com" },
111 { id: 2, name: "Bob", email: "bob@example.com" },
112 ]
113
114 export async function GET() {
115 return NextResponse.json(users)
116 }
117
118 export async function POST(request: Request) {
119 const body = await request.json()
120 const newUser = { id: users.length + 1, ...body }
121 users.push(newUser)
122 return NextResponse.json(newUser, { status: 201 })
123 }`,
124 "src/components/ui/button.tsx": `import * as React from "react"
125 import { cva, type VariantProps } from "class-variance-authority"
126 import { cn } from "@/lib/utils"
127
128 const buttonVariants = cva(
129 "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
130 {
131 variants: {
132 variant: {
133 default: "bg-primary text-primary-foreground hover:bg-primary/90",
134 destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
135 outline: "border border-input hover:bg-accent hover:text-accent-foreground",
136 secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
137 ghost: "hover:bg-accent hover:text-accent-foreground",
138 link: "underline-offset-4 hover:underline text-primary",
139 },
140 size: {
141 default: "h-10 px-4 py-2",
142 sm: "h-9 rounded-md px-3",
143 lg: "h-11 rounded-md px-8",
144 icon: "h-10 w-10",
145 },
146 },
147 defaultVariants: {
148 variant: "default",
149 size: "default",
150 },
151 }
152 )
153
154 export interface ButtonProps
155 extends React.ButtonHTMLAttributes<HTMLButtonElement>,
156 VariantProps<typeof buttonVariants> {}
157
158 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
159 function Button({ className, variant, size, ...props }, ref) {
160 return (
161 <button
162 className={cn(buttonVariants({ variant, size, className }))}
163 ref={ref}
164 {...props}
165 />
166 )
167 }
168 )
169
170 export { Button, buttonVariants }`,
171 "src/components/ui/card.tsx": `import * as React from "react"
172 import { cn } from "@/lib/utils"
173
174 const Card = React.forwardRef<
175 HTMLDivElement,
176 React.HTMLAttributes<HTMLDivElement>
177 >(function Card({ className, ...props }, ref) {
178 return (
179 <div
180 ref={ref}
181 className={cn(
182 "rounded-lg border bg-card text-card-foreground shadow-sm",
183 className
184 )}
185 {...props}
186 />
187 )
188 })
189
190 const CardHeader = React.forwardRef<
191 HTMLDivElement,
192 React.HTMLAttributes<HTMLDivElement>
193 >(function CardHeader({ className, ...props }, ref) {
194 return (
195 <div
196 ref={ref}
197 className={cn("flex flex-col space-y-1.5 p-6", className)}
198 {...props}
199 />
200 )
201 })
202
203 const CardTitle = React.forwardRef<
204 HTMLParagraphElement,
205 React.HTMLAttributes<HTMLHeadingElement>
206 >(function CardTitle({ className, ...props }, ref) {
207 return (
208 <h3
209 ref={ref}
210 className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
211 {...props}
212 />
213 )
214 })
215
216 export { Card, CardHeader, CardTitle }`,
217 "src/components/ui/file-tree.tsx": `// See the actual file-tree.tsx component
218 // This is a simplified preview
219
220 import { cva } from "class-variance-authority"
221
222 export const fileTreeVariants = cva(
223 "font-mono text-sm select-none",
224 {
225 variants: {
226 variant: {
227 default: "bg-background text-foreground",
228 ghost: "bg-transparent",
229 bordered: "bg-background border border-border rounded-lg p-2",
230 elevated: "bg-card text-card-foreground shadow-md rounded-lg p-3",
231 },
232 },
233 }
234 )`,
235 "src/components/header.tsx": `import Link from "next/link"
236 import { Button } from "@/components/ui/button"
237
238 export function Header() {
239 return (
240 <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur">
241 <div className="container flex h-14 items-center">
242 <Link href="/" className="mr-6 flex items-center space-x-2">
243 <span className="font-bold">My App</span>
244 </Link>
245 <nav className="flex flex-1 items-center space-x-6 text-sm font-medium">
246 <Link href="/about">About</Link>
247 <Link href="/docs">Docs</Link>
248 </nav>
249 <Button size="sm">Sign In</Button>
250 </div>
251 </header>
252 )
253 }`,
254 "src/components/footer.tsx": `export function Footer() {
255 return (
256 <footer className="border-t py-6 md:py-0">
257 <div className="container flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row">
258 <p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
259 Built with Next.js and Tailwind CSS.
260 </p>
261 </div>
262 </footer>
263 )
264 }`,
265 "src/lib/utils.ts": `import { type ClassValue, clsx } from "clsx"
266 import { twMerge } from "tailwind-merge"
267
268 export function cn(...inputs: ClassValue[]) {
269 return twMerge(clsx(inputs))
270 }`,
271 "src/lib/constants.ts": `export const APP_NAME = "My Application"
272 export const APP_VERSION = "1.0.0"
273 export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"
274
275 export const ROUTES = {
276 home: "/",
277 about: "/about",
278 dashboard: "/dashboard",
279 settings: "/settings",
280 } as const`,
281 "src/hooks/use-auth.ts": `import { useState, useEffect, useCallback } from "react"
282
283 interface User {
284 id: string
285 email: string
286 name: string
287 }
288
289 export function useAuth() {
290 const [user, setUser] = useState<User | null>(null)
291 const [loading, setLoading] = useState(true)
292
293 useEffect(() => {
294 // Check for existing session
295 const checkAuth = async () => {
296 try {
297 const response = await fetch("/api/auth/me")
298 if (response.ok) {
299 const userData = await response.json()
300 setUser(userData)
301 }
302 } catch (error) {
303 console.error("Auth check failed:", error)
304 } finally {
305 setLoading(false)
306 }
307 }
308 checkAuth()
309 }, [])
310
311 const signOut = useCallback(async () => {
312 await fetch("/api/auth/signout", { method: "POST" })
313 setUser(null)
314 }, [])
315
316 return { user, loading, signOut }
317 }`,
318 "src/hooks/use-theme.ts": `import { useState, useEffect } from "react"
319
320 type Theme = "light" | "dark" | "system"
321
322 export function useTheme() {
323 const [theme, setTheme] = useState<Theme>("system")
324
325 useEffect(() => {
326 const stored = localStorage.getItem("theme") as Theme | null
327 if (stored) {
328 setTheme(stored)
329 }
330 }, [])
331
332 useEffect(() => {
333 const root = document.documentElement
334 root.classList.remove("light", "dark")
335
336 if (theme === "system") {
337 const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
338 ? "dark"
339 : "light"
340 root.classList.add(systemTheme)
341 } else {
342 root.classList.add(theme)
343 }
344
345 localStorage.setItem("theme", theme)
346 }, [theme])
347
348 return { theme, setTheme }
349 }`,
350 "package.json": `{
351 "name": "my-nextjs-app",
352 "version": "0.1.0",
353 "private": true,
354 "scripts": {
355 "dev": "next dev",
356 "build": "next build",
357 "start": "next start",
358 "lint": "next lint"
359 },
360 "dependencies": {
361 "next": "14.2.0",
362 "react": "^18",
363 "react-dom": "^18",
364 "class-variance-authority": "^0.7.0",
365 "clsx": "^2.1.0",
366 "tailwind-merge": "^2.2.0",
367 "lucide-react": "^0.344.0"
368 },
369 "devDependencies": {
370 "typescript": "^5",
371 "@types/node": "^20",
372 "@types/react": "^18",
373 "@types/react-dom": "^18",
374 "tailwindcss": "^3.4.1",
375 "postcss": "^8"
376 }
377 }`,
378 "tsconfig.json": `{
379 "compilerOptions": {
380 "lib": ["dom", "dom.iterable", "esnext"],
381 "allowJs": true,
382 "skipLibCheck": true,
383 "strict": true,
384 "noEmit": true,
385 "esModuleInterop": true,
386 "module": "esnext",
387 "moduleResolution": "bundler",
388 "resolveJsonModule": true,
389 "isolatedModules": true,
390 "jsx": "preserve",
391 "incremental": true,
392 "plugins": [{ "name": "next" }],
393 "paths": {
394 "@/*": ["./*"]
395 }
396 },
397 "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
398 "exclude": ["node_modules"]
399 }`,
400 "next.config.mjs": `/** @type {import('next').NextConfig} */
401 const nextConfig = {
402 reactStrictMode: true,
403 images: {
404 domains: [],
405 },
406 }
407
408 export default nextConfig`,
409 "tailwind.config.ts": `import type { Config } from "tailwindcss"
410
411 const config: Config = {
412 darkMode: ["class"],
413 content: [
414 "./pages/**/*.{js,ts,jsx,tsx,mdx}",
415 "./components/**/*.{js,ts,jsx,tsx,mdx}",
416 "./app/**/*.{js,ts,jsx,tsx,mdx}",
417 ],
418 theme: {
419 extend: {
420 colors: {
421 border: "hsl(var(--border))",
422 background: "hsl(var(--background))",
423 foreground: "hsl(var(--foreground))",
424 },
425 },
426 },
427 plugins: [],
428 }
429 export default config`,
430 ".env.local": `# Environment Variables
431 DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
432 NEXT_PUBLIC_API_URL="http://localhost:3000/api"
433 AUTH_SECRET="your-secret-key-here"`,
434 ".gitignore": `# Dependencies
435 node_modules
436 .pnp
437 .pnp.js
438
439 # Testing
440 coverage
441
442 # Next.js
443 .next/
444 out/
445
446 # Production
447 build
448
449 # Misc
450 .DS_Store
451 *.pem
452
453 # Debug
454 npm-debug.log*
455
456 # Local env files
457 .env*.local
458
459 # Vercel
460 .vercel
461
462 # TypeScript
463 *.tsbuildinfo
464 next-env.d.ts`,
465 "README.md": `# My Next.js App
466
467 A modern web application built with Next.js 14, React, and Tailwind CSS.
468
469 ## Getting Started
470
471 First, install dependencies:
472
473 \`\`\`bash
474 npm install
475 # or
476 yarn install
477 # or
478 pnpm install
479 \`\`\`
480
481 Then, run the development server:
482
483 \`\`\`bash
484 npm run dev
485 \`\`\`
486
487 Open [http://localhost:3000](http://localhost:3000) to see the result.
488
489 ## Features
490
491 - Next.js 14 App Router
492 - TypeScript
493 - Tailwind CSS
494 - Component library with CVA variants
495 `,
496};
497
498const sampleData: FileTreeNode[] = [
499 {
500 name: "src",
501 type: "folder",
502 children: [
503 {
504 name: "app",
505 type: "folder",
506 children: [
507 { name: "layout.tsx", type: "file" },
508 { name: "page.tsx", type: "file" },
509 { name: "globals.css", type: "file" },
510 {
511 name: "api",
512 type: "folder",
513 children: [
514 {
515 name: "auth",
516 type: "folder",
517 children: [{ name: "route.ts", type: "file" }],
518 },
519 {
520 name: "users",
521 type: "folder",
522 children: [{ name: "route.ts", type: "file" }],
523 },
524 ],
525 },
526 ],
527 },
528 {
529 name: "components",
530 type: "folder",
531 children: [
532 {
533 name: "ui",
534 type: "folder",
535 children: [
536 { name: "button.tsx", type: "file" },
537 { name: "card.tsx", type: "file" },
538 { name: "file-tree.tsx", type: "file" },
539 ],
540 },
541 { name: "header.tsx", type: "file" },
542 { name: "footer.tsx", type: "file" },
543 ],
544 },
545 {
546 name: "lib",
547 type: "folder",
548 children: [
549 { name: "utils.ts", type: "file" },
550 { name: "constants.ts", type: "file" },
551 ],
552 },
553 {
554 name: "hooks",
555 type: "folder",
556 children: [
557 { name: "use-auth.ts", type: "file" },
558 { name: "use-theme.ts", type: "file" },
559 ],
560 },
561 ],
562 },
563 {
564 name: "public",
565 type: "folder",
566 children: [
567 { name: "favicon.ico", type: "file" },
568 { name: "logo.svg", type: "file" },
569 {
570 name: "images",
571 type: "folder",
572 children: [
573 { name: "hero.png", type: "file" },
574 { name: "avatar.jpg", type: "file" },
575 ],
576 },
577 ],
578 },
579 { name: "package.json", type: "file" },
580 { name: "tsconfig.json", type: "file" },
581 { name: "next.config.mjs", type: "file" },
582 { name: "tailwind.config.ts", type: "file" },
583 { name: ".env.local", type: "file" },
584 { name: ".gitignore", type: "file" },
585 { name: "README.md", type: "file" },
586];
587
588const imagePreviewUrls: Record<string, string> = {
589 "public/images/hero.png":
590 "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=1200&auto=format&fit=crop",
591 "public/images/avatar.jpg":
592 "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?q=80&w=400&auto=format&fit=crop",
593 "public/logo.svg": "/file.svg",
594 "public/favicon.ico": "https://picsum.photos/seed/favicon/64/64",
595};
596
597type OpenFileTab = FileContent & { id: string };
598
599function getTabFileIcon(filename: string) {
600 const iconClass = "shrink-0";
601 const ext = filename.split(".").pop()?.toLowerCase();
602
603 const iconMap: Record<string, ReactNode> = {
604 js: <FileCode className={cn(iconClass, "text-yellow-500")} size={14} />,
605 jsx: <FileCode className={cn(iconClass, "text-yellow-500")} size={14} />,
606 ts: <FileCode className={cn(iconClass, "text-blue-500")} size={14} />,
607 tsx: <FileCode className={cn(iconClass, "text-blue-500")} size={14} />,
608 json: <FileJson className={cn(iconClass, "text-yellow-600")} size={14} />,
609 md: (
610 <FileText className={cn(iconClass, "text-muted-foreground")} size={14} />
611 ),
612 css: <FileCode className={cn(iconClass, "text-blue-400")} size={14} />,
613 svg: <ImageIcon className={cn(iconClass, "text-orange-400")} size={14} />,
614 png: <ImageIcon className={cn(iconClass, "text-green-500")} size={14} />,
615 jpg: <ImageIcon className={cn(iconClass, "text-green-500")} size={14} />,
616 };
617
618 if (filename === "package.json") {
619 return <Package className={cn(iconClass, "text-green-600")} size={14} />;
620 }
621 if (filename.startsWith(".env")) {
622 return <Cog className={cn(iconClass, "text-yellow-600")} size={14} />;
623 }
624 if (filename.includes("config")) {
625 return <Cog className={cn(iconClass, "text-muted-foreground")} size={14} />;
626 }
627
628 return (
629 iconMap[ext || ""] || (
630 <File className={cn(iconClass, "text-muted-foreground")} size={14} />
631 )
632 );
633}
634
635function buildFileContent(node: FileTreeNode, path: string): FileContent {
636 const content = fileContents[path];
637 const previewUrl = imagePreviewUrls[path];
638
639 if (content) {
640 return { path, name: node.name, content, previewUrl };
641 }
642
643 return {
644 path,
645 name: node.name,
646 content: previewUrl
647 ? ""
648 : `// Content for ${node.name} not available in demo`,
649 previewUrl,
650 };
651}
652
653type FlatFileEntry = {
654 path: string;
655 name: string;
656 node: FileTreeNode;
657};
658
659function flattenFilePaths(nodes: FileTreeNode[], prefix = ""): FlatFileEntry[] {
660 const entries: FlatFileEntry[] = [];
661
662 for (const node of nodes) {
663 const path = prefix ? `${prefix}/${node.name}` : node.name;
664 if (node.type === "file") {
665 entries.push({ path, name: node.name, node });
666 } else if (node.children?.length) {
667 entries.push(...flattenFilePaths(node.children, path));
668 }
669 }
670
671 return entries;
672}
673
674const mockExtensions = [
675 {
676 id: "eslint",
677 name: "ESLint",
678 publisher: "Microsoft",
679 description: "Integrates ESLint into your workspace.",
680 },
681 {
682 id: "prettier",
683 name: "Prettier",
684 publisher: "Prettier",
685 description: "Code formatter using Prettier.",
686 },
687 {
688 id: "tailwind",
689 name: "Tailwind CSS IntelliSense",
690 publisher: "Tailwind Labs",
691 description: "Intelligent Tailwind class completion and linting.",
692 },
693 {
694 id: "gitlens",
695 name: "GitLens",
696 publisher: "GitKraken",
697 description: "Supercharge Git capabilities built into your editor.",
698 },
699];
700
701export function FileViewerWithEditorSidebar() {
702 const { activeId: sidebarView, setActiveId: setSidebarView } =
703 useEditorSidebar("explorer");
704 const { tabs, activeId, setActiveId, openTab, closeTab, focusTab } =
705 useEditorTabs<OpenFileTab>();
706 const [searchQuery, setSearchQuery] = useState("");
707 const [explorerPanelOpen, setExplorerPanelOpen] = useState(true);
708
709 const activeFile = useMemo(
710 () => tabs.find((tab) => tab.id === activeId) ?? null,
711 [tabs, activeId],
712 );
713
714 const allFiles = useMemo(() => flattenFilePaths(sampleData), []);
715
716 const searchResults = useMemo(() => {
717 const query = searchQuery.trim().toLowerCase();
718 if (!query) return allFiles.slice(0, 8);
719 return allFiles.filter(
720 (file) =>
721 file.path.toLowerCase().includes(query) ||
722 file.name.toLowerCase().includes(query),
723 );
724 }, [allFiles, searchQuery]);
725
726 const handleSelect = (node: FileTreeNode, path: string) => {
727 if (node.type !== "file") return;
728
729 const file = buildFileContent(node, path);
730 const tab: OpenFileTab = { ...file, id: path };
731
732 if (tabs.some((item) => item.id === path)) {
733 focusTab(path);
734 return;
735 }
736
737 openTab(tab);
738 };
739
740 const handleSearchSelect = (entry: FlatFileEntry) => {
741 handleSelect(entry.node, entry.path);
742 setSidebarView("explorer");
743 };
744
745 return (
746 <div className="w-full">
747 <FileExplorer variant="elevated" rounded="lg" className="h-[600px]">
748 <EditorSidebar
749 value={sidebarView}
750 onValueChange={setSidebarView}
751 variant="ghost"
752 className="h-full"
753 >
754 <EditorSidebarRail className="h-full min-h-0 self-stretch">
755 <div className="flex min-h-0 flex-1 flex-col gap-1">
756 <EditorSidebarTrigger
757 value="explorer"
758 icon={<Files />}
759 label="Explorer"
760 />
761 <EditorSidebarTrigger
762 value="search"
763 icon={<Search />}
764 label="Search"
765 />
766 <EditorSidebarTrigger
767 value="extensions"
768 icon={<Blocks />}
769 label="Extensions"
770 badge
771 />
772 </div>
773 <Button
774 type="button"
775 variant="ghost"
776 size="icon"
777 className="mt-1 size-9 shrink-0 text-muted-foreground"
778 onClick={() => setExplorerPanelOpen((open) => !open)}
779 aria-label={
780 explorerPanelOpen
781 ? "Collapse explorer panel"
782 : "Expand explorer panel"
783 }
784 aria-expanded={explorerPanelOpen}
785 title={explorerPanelOpen ? "Collapse panel" : "Expand panel"}
786 >
787 {explorerPanelOpen ? (
788 <PanelLeftClose className="size-4" />
789 ) : (
790 <PanelLeft className="size-4" />
791 )}
792 </Button>
793 </EditorSidebarRail>
794 <div
795 className={cn(
796 "flex h-full min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-linear",
797 explorerPanelOpen ? "w-[260px]" : "w-0",
798 )}
799 aria-hidden={!explorerPanelOpen}
800 >
801 <EditorSidebarContent
802 width="260px"
803 className="h-full min-h-0 min-w-[260px] flex-none"
804 >
805 <EditorSidebarPanel value="explorer" title="Explorer">
806 <FileTree
807 data={sampleData}
808 variant="ghost"
809 size="default"
810 onSelect={handleSelect}
811 selectedPath={activeId}
812 />
813 </EditorSidebarPanel>
814 <EditorSidebarPanel value="search" title="Search">
815 <div className="flex flex-col gap-3">
816 <input
817 type="search"
818 value={searchQuery}
819 onChange={(event) => setSearchQuery(event.target.value)}
820 placeholder="Search files..."
821 className="h-8 w-full rounded-md border border-border bg-background px-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
822 />
823 <ul className="flex flex-col gap-0.5">
824 {searchResults.length === 0 ? (
825 <li className="px-1 py-2 text-xs text-muted-foreground">
826 No files match your search.
827 </li>
828 ) : (
829 searchResults.map((file) => (
830 <li key={file.path}>
831 <button
832 type="button"
833 onClick={() => handleSearchSelect(file)}
834 className="flex w-full items-center gap-2 rounded-sm px-1.5 py-1 text-left text-xs hover:bg-accent"
835 >
836 {getTabFileIcon(file.name)}
837 <span className="truncate font-mono">
838 {file.path}
839 </span>
840 </button>
841 </li>
842 ))
843 )}
844 </ul>
845 </div>
846 </EditorSidebarPanel>
847 <EditorSidebarPanel value="extensions" title="Extensions">
848 <ul className="flex flex-col gap-2">
849 {mockExtensions.map((extension) => (
850 <li
851 key={extension.id}
852 className="rounded-md border border-border p-2.5"
853 >
854 <p className="text-sm font-medium">{extension.name}</p>
855 <p className="text-xs text-muted-foreground">
856 {extension.publisher}
857 </p>
858 <p className="mt-1 text-xs text-muted-foreground">
859 {extension.description}
860 </p>
861 </li>
862 ))}
863 </ul>
864 </EditorSidebarPanel>
865 </EditorSidebarContent>
866 </div>
867 </EditorSidebar>
868 <FileExplorerContent className="flex flex-col">
869 <EditorTabs
870 value={activeId}
871 onValueChange={setActiveId}
872 variant="ghost"
873 className="h-full"
874 >
875 {tabs.length > 0 ? (
876 <EditorTabsList>
877 {tabs.map((tab) => (
878 <EditorTab
879 key={tab.id}
880 value={tab.id}
881 icon={getTabFileIcon(tab.name)}
882 onClose={closeTab}
883 >
884 {tab.name}
885 </EditorTab>
886 ))}
887 </EditorTabsList>
888 ) : null}
889 <EditorTabsPanel>
890 <FileViewer
891 file={activeFile}
892 variant="ghost"
893 size="default"
894 rounded="none"
895 maxHeight="100%"
896 showHeader={false}
897 className="h-full"
898 />
899 </EditorTabsPanel>
900 </EditorTabs>
901 </FileExplorerContent>
902 </FileExplorer>
903 </div>
904 );
905}

Installation & source

Install via the shadcn CLI or copy the registry files manually.

bash
npx shadcn@latest add @tt-ui/editor-tabs

Props

NameTypeDefaultDescription
value / onValueChangestring / (value: string) => voiduncontrolled if omittedControlled active tab id
defaultValuestringundefinedInitial active tab in uncontrolled mode
variant (CVA)"default" | "ghost" | "bordered""default"Visual style for the tab bar container
size (CVA)"sm" | "default" | "lg""default"Tab bar and trigger sizing
EditorTab.iconReactNodeundefinedOptional leading icon slot
EditorTab.dirtybooleanfalseShows unsaved-changes indicator dot
EditorTab.pinnedbooleanfalsePinned tabs hide the close button
EditorTab.onClose(value: string) => voidundefinedWhen provided, renders close button and enables middle-click close
useEditorTabshookundefinedOptional helper for open/close/focus tab state