Skip to content
All Taste components

Tabs

Boxed tabs with descriptions

Card-like tabs with a title and a one-line explanation, for choices that need a word of context.

icon + title + description · selected outline and tint · wraps to rows · switching panels

DATABASE_URL••••••••••••••
PAYMENTS_KEYlive_••••••••
SITE_URLhttps://kestrelrow.example
DATABASE_URL••••••••••••••
PAYMENTS_KEYtest_••••••••
DATABASE_URLlocalhost:5432
PAYMENTS_KEYtest_••••••••
DEBUGtrue
"use client";
import { useRef, useState } from "react";
import { GitBranch, Globe, Laptop, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";

type Tab = { id: string; title: string; description: string; icon: LucideIcon; content: React.ReactNode };

const tabs: Tab[] = [
  { id: "production", title: "Production", description: "Your live site and its visitors.", icon: Globe, content: <p>Production variables</p> },
  { id: "preview", title: "Preview", description: "Every branch gets its own link.", icon: GitBranch, content: <p>Preview variables</p> },
  { id: "development", title: "Development", description: "Used when you run it locally.", icon: Laptop, content: <p>Development variables</p> },
];

export function BoxedTabs() {
  const [active, setActive] = useState(0);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const last = tabs.length - 1;
    const keys: Record<string, number> = {
      ArrowRight: active === last ? 0 : active + 1,
      ArrowLeft: active === 0 ? last : active - 1,
      Home: 0,
      End: last,
    };
    const next = keys[e.key];
    if (next === undefined) return;
    e.preventDefault();
    setActive(next);
    refs.current[next]?.focus();
  };

  return (
    <div className="grid gap-3.5 font-sans">
      <div role="tablist" aria-label="Environment" className="grid grid-cols-[repeat(auto-fit,minmax(148px,1fr))] gap-2">
        {tabs.map(({ id, title, description, icon: Icon }, i) => {
          const selected = active === i;
          return (
            <button
              key={id}
              ref={(el) => { refs.current[i] = el; }}
              id={"env-tab-" + id}
              type="button"
              role="tab"
              aria-selected={selected}
              aria-controls={"env-panel-" + id}
              tabIndex={selected ? 0 : -1}
              onClick={() => setActive(i)}
              onKeyDown={onKeyDown}
              className={cn(
                "grid content-start gap-0.5 rounded-lg border px-3.5 py-3 text-left transition",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
                selected ? "border-primary bg-primary-soft/40 ring-1 ring-inset ring-primary" : "border-border bg-bg hover:border-border-strong",
              )}
            >
              <span className="flex items-center gap-2 text-[13.5px] font-medium text-fg">
                <Icon className={cn("size-4", selected ? "text-primary" : "text-fg-subtle")} aria-hidden />
                {title}
              </span>
              <span className="text-[12.5px] leading-snug text-fg-muted">{description}</span>
            </button>
          );
        })}
      </div>
      {tabs.map((tab, i) => (
        <div key={tab.id} id={"env-panel-" + tab.id} role="tabpanel" aria-labelledby={"env-tab-" + tab.id} hidden={active !== i} tabIndex={0}
          className="rounded-lg border border-border bg-bg p-4 text-sm text-fg-muted">
          {tab.content}
        </div>
      ))}
    </div>
  );
}

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