# Tabs Folder — React Tabs Component

> Browser-style tabs with scooped corners: the active shape slides between tabs, and tabs can be closed or added with layout springs.

- Category: [Tabs](https://pixelpapa.com/components/tabs.md)
- Tags: tabs, browser, folder, closable, layout
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/tabs/tabs-folder

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/tabs-folder.json"
```

The file is written to `components/pixelpapa/tabs-folder.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Lock, Plus, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type FolderTab = { id: string; title: string; url: string; color: string };

type FolderTabsProps = {
  tabs: FolderTab[];
  activeId: string;
  onSelect: (id: string) => void;
  onClose?: (id: string) => void;
  onAdd?: () => void;
  className?: string;
};

// Shared by the tab shape, the tabs and the add button so they all move as one.
const spring = { type: "spring", stiffness: 440, damping: 36 } as const;

export function FolderTabs({ tabs, activeId, onSelect, onClose, onAdd, className }: FolderTabsProps) {
  const uid = useId();

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const i = tabs.findIndex((t) => t.id === activeId);
    let next = i;
    if (e.key === "ArrowRight") next = (i + 1) % tabs.length;
    else if (e.key === "ArrowLeft") next = (i - 1 + tabs.length) % tabs.length;
    else if ((e.key === "Delete" || e.key === "Backspace") && onClose && tabs.length > 1) return onClose(activeId);
    else return;
    e.preventDefault();
    onSelect(tabs[next].id);
    e.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
  }

  return (
    <div role="tablist" onKeyDown={onKeyDown} className={cn("flex items-end px-3 pt-2", className)}>
      <AnimatePresence initial={false} mode="popLayout">
        {tabs.map((tab) => {
          const selected = tab.id === activeId;
          return (
            <motion.div
              layout
              key={tab.id}
              initial={{ opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: 12, transition: { duration: 0.12 } }}
              transition={spring}
              className="group relative h-9 min-w-0 flex-1 basis-0 sm:max-w-44"
            >
              {selected ? (
                // The scooped feet are radial-gradient cut-outs, so the tab melts into the panel below.
                <motion.span
                  layoutId={`tabs-folder-shape-${uid}`}
                  transition={spring}
                  className="absolute inset-0 rounded-t-xl bg-card before:absolute before:bottom-0 before:-left-3 before:size-3 before:[background:radial-gradient(circle_at_0_0,transparent_11.5px,var(--card)_12px)] after:absolute after:-right-3 after:bottom-0 after:size-3 after:[background:radial-gradient(circle_at_100%_0,transparent_11.5px,var(--card)_12px)]"
                />
              ) : (
                <span className="absolute inset-x-1 top-0 bottom-1.5 rounded-lg transition-colors group-hover:bg-foreground/6" />
              )}
              <button
                role="tab"
                aria-selected={selected}
                tabIndex={selected ? 0 : -1}
                onClick={() => onSelect(tab.id)}
                className={cn(
                  "relative flex size-full items-center gap-2 rounded-t-xl pr-8 pl-3.5 text-left text-[13px] outline-offset-[-2px] transition-colors",
                  selected ? "font-medium text-card-foreground" : "text-muted-foreground hover:text-foreground",
                )}
              >
                <span className="size-3 shrink-0 rounded-[4px]" style={{ background: tab.color }} />
                <span className="truncate">{tab.title}</span>
              </button>
              {onClose && tabs.length > 1 && (
                <button
                  aria-label={`Close ${tab.title}`}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => onClose(tab.id)}
                  className={cn(
                    "absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition hover:bg-foreground/10 hover:text-foreground",
                    !selected && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
                  )}
                >
                  <X className="size-3" />
                </button>
              )}
            </motion.div>
          );
        })}
      </AnimatePresence>
      {onAdd && (
        <motion.button
          layout
          transition={spring}
          aria-label="New tab"
          onClick={onAdd}
          whileTap={{ scale: 0.88 }}
          className="mb-1.5 ml-2 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground"
        >
          <Plus className="size-4" />
        </motion.button>
      )}
    </div>
  );
}

const initial: FolderTab[] = [
  { id: "roadmap", title: "Roadmap", url: "northwind.app/roadmap", color: "var(--chart-1)" },
  { id: "invoices", title: "Invoices", url: "northwind.app/billing/invoices", color: "var(--chart-4)" },
  { id: "handbook", title: "Team handbook", url: "docs.northwind.app/handbook", color: "var(--chart-5)" },
];

const spare = [
  { title: "Changelog", url: "northwind.app/changelog", color: "var(--chart-3)" },
  { title: "Status", url: "status.northwind.app", color: "var(--chart-2)" },
];

export default function TabsFolderDemo() {
  const [tabs, setTabs] = useState(initial);
  const [activeId, setActiveId] = useState("roadmap");
  const counter = useRef(0);
  const active = tabs.find((t) => t.id === activeId) ?? tabs[0];

  function close(id: string) {
    const i = tabs.findIndex((t) => t.id === id);
    const rest = tabs.filter((t) => t.id !== id);
    setTabs(rest);
    if (id === activeId) setActiveId(rest[Math.min(i, rest.length - 1)].id);
  }

  function add() {
    if (tabs.length >= 5) return;
    const n = counter.current++;
    const tab = { ...spare[n % spare.length], id: `new-${n}` };
    setTabs([...tabs, tab]);
    setActiveId(tab.id);
  }

  return (
    <div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-border bg-muted shadow-lg shadow-black/5">
      <FolderTabs tabs={tabs} activeId={active.id} onSelect={setActiveId} onClose={close} onAdd={add} />
      <div role="tabpanel" aria-label={active.title} className="bg-card">
        <div className="flex items-center gap-2 border-b border-border px-4 py-2">
          <span className="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-muted px-3 py-1.5 text-xs text-muted-foreground">
            <Lock className="size-3 shrink-0" />
            <span className="truncate">{active.url}</span>
          </span>
        </div>
        <div className="h-56 overflow-hidden p-6">
          <AnimatePresence mode="wait" initial={false}>
            <motion.div
              key={active.id}
              initial={{ opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.18 }}
            >
              <div className="flex items-center gap-3">
                <span className="size-9 rounded-xl" style={{ background: active.color }} />
                <div>
                  <h3 className="text-lg font-semibold tracking-tight text-card-foreground">{active.title}</h3>
                  <p className="text-xs text-muted-foreground">Updated 12 minutes ago · 4 collaborators</p>
                </div>
              </div>
              <div className="mt-5 grid grid-cols-3 gap-3">
                {[0.16, 0.1, 0.06].map((alpha, i) => (
                  <div key={i} className="relative h-24 overflow-hidden rounded-xl border border-border p-3">
                    <span className="absolute inset-0" style={{ background: active.color, opacity: alpha }} />
                    <span className="relative block h-2 w-3/5 rounded-full bg-foreground/20" />
                    <span className="relative mt-2 block h-2 w-4/5 rounded-full bg-foreground/10" />
                    <span className="relative mt-2 block h-2 w-2/5 rounded-full bg-foreground/10" />
                  </div>
                ))}
              </div>
            </motion.div>
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Tabs Folder" component from PixelPapa UI to this project.

What it is: Browser-style tabs with scooped corners: the active shape slides between tabs, and tabs can be closed or added with layout springs.
Category: Tabs. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/tabs-folder.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/tabs-folder.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/tabs-folder" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More tabs

- [Tabs Animated Height Content](https://pixelpapa.com/components/tabs/tabs-animated-height-content.md): Tabs inside a card that springs to the height of each panel, so short and long content swap without a layout jump.
- [Tabs Scrollable Overflow](https://pixelpapa.com/components/tabs/tabs-scrollable-overflow.md) (Pro): A tab strip that scrolls when it runs out of room: edge fades and arrow buttons appear only on the side that has more, and the active tab centres itself.
- [Tabs Segmented Icons](https://pixelpapa.com/components/tabs/tabs-segmented-icons.md): An icon segmented control whose active segment unfolds its label, driving a task view where the same cards re-flow between list, board, grid and timeline.
- [Tabs Sliding Pill](https://pixelpapa.com/components/tabs/tabs-sliding-pill.md) (Pro): A pill glides between tabs on a shared-layout spring while the panel slides in from the side you moved toward.
- [Tabs Underline Spring](https://pixelpapa.com/components/tabs/tabs-underline-spring.md) (Pro): An underline that stretches toward the next tab before its tail catches up, with a hover highlight that follows the pointer.
- [Tabs Vertical With Preview](https://pixelpapa.com/components/tabs/tabs-vertical-with-preview.md) (Pro): Vertical feature tabs that auto-advance with a filling rail and swap a drawn product preview, sliding up or down by direction.
