Search

Search the site

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

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