Skip to content
All Taste components

Tabs

Closeable tabs

Editor-style tabs for open files, each with a close button, an unsaved dot and a button to open another.

active top bar · close on hover · unsaved dot · add button · switching file panels

import { Button } from "./button"; export default function Page() { return <Button>Book a table</Button>;}
// Unsaved: new size propexport function Button({ size = "md", ...props }) { return <button data-size={size} {...props} />;}
:root { --radius: 10px; --font: "Inter", sans-serif;}
"use client";
import { useRef, useState } from "react";
import { FileCode2, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";

type Doc = { id: string; name: string; dirty?: boolean; body: string };

export function CloseableTabs({ initial, onAdd }: { initial: Doc[]; onAdd?: () => Doc }) {
  const [docs, setDocs] = useState(initial);
  const [activeId, setActiveId] = useState(initial[0]?.id);
  const refs = useRef<Record<string, HTMLButtonElement | null>>({});
  const index = docs.findIndex((d) => d.id === activeId);

  const focus = (id: string) => { setActiveId(id); refs.current[id]?.focus(); };
  const close = (id: string) => {
    const i = docs.findIndex((d) => d.id === id);
    const rest = docs.filter((d) => d.id !== id);
    setDocs(rest);
    if (id === activeId) setActiveId(rest[Math.min(i, rest.length - 1)]?.id);
  };
  const add = () => {
    if (!onAdd) return;
    const doc = onAdd();
    setDocs((d) => [...d, doc]);
    setActiveId(doc.id);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const last = docs.length - 1;
    const keys: Record<string, number> = {
      ArrowRight: index === last ? 0 : index + 1,
      ArrowLeft: index <= 0 ? last : index - 1,
      Home: 0,
      End: last,
    };
    if (e.key === "Delete" && activeId) { e.preventDefault(); close(activeId); return; }
    const next = keys[e.key];
    if (next === undefined || !docs[next]) return;
    e.preventDefault();
    focus(docs[next].id);
  };

  return (
    <div className="overflow-hidden rounded-lg border border-border bg-bg font-sans">
      <div className="flex items-stretch bg-surface shadow-[inset_0_-1px_0] shadow-border">
        <div role="tablist" aria-label="Open files" className="flex min-w-0 overflow-x-auto [scrollbar-width:none]">
          {docs.map((d) => {
            const selected = d.id === activeId;
            return (
              <div key={d.id} role="presentation" className={cn("group relative flex shrink-0 items-center border-r border-border", selected && "bg-bg before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-primary")}>
                <button
                  ref={(el) => { refs.current[d.id] = el; }}
                  id={"file-tab-" + d.id}
                  type="button"
                  role="tab"
                  aria-selected={selected}
                  aria-controls={"file-panel-" + d.id}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setActiveId(d.id)}
                  onKeyDown={onKeyDown}
                  className={cn("flex h-[38px] items-center gap-1.5 whitespace-nowrap pl-3 pr-1 text-[12.5px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", selected ? "text-fg" : "text-fg-muted group-hover:text-fg")}
                >
                  <FileCode2 className={cn("size-3.5", selected ? "text-primary" : "text-fg-subtle")} aria-hidden />
                  {d.name}
                </button>
                <button
                  type="button"
                  aria-label={"Close " + d.name + (d.dirty ? " (unsaved changes)" : "")}
                  onClick={() => close(d.id)}
                  className={cn(
                    "relative mr-1.5 grid size-[22px] place-items-center rounded-sm text-fg-subtle hover:bg-surface-2 hover:text-fg",
                    !selected && !d.dirty && "opacity-0 focus-visible:opacity-100 group-hover:opacity-100",
                  )}
                >
                  {d.dirty ? (
                    <>
                      <span className="size-2 rounded-full bg-fg-muted group-hover:hidden" aria-hidden />
                      <X className="hidden size-[13px] group-hover:block" aria-hidden />
                    </>
                  ) : (
                    <X className="size-[13px]" aria-hidden />
                  )}
                </button>
              </div>
            );
          })}
        </div>
        {onAdd && (
          <button type="button" aria-label="Open a file" onClick={add} className="grid w-[38px] shrink-0 place-items-center text-fg-muted hover:bg-surface-2 hover:text-fg">
            <Plus className="size-[15px]" aria-hidden />
          </button>
        )}
      </div>
      {docs.map((d) => (
        <pre
          key={d.id}
          id={"file-panel-" + d.id}
          role="tabpanel"
          aria-labelledby={"file-tab-" + d.id}
          hidden={d.id !== activeId}
          tabIndex={0}
          className="overflow-x-auto px-4 py-3 font-mono text-[12.5px] leading-7 text-fg"
        >
          {d.body}
        </pre>
      ))}
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.