Skip to content
All Taste components

Tabs

Vertical tabs

A settings layout with tabs down the side and the chosen section beside them, turning into a top row on narrow screens.

side list with icons · section label · 5 panels with fields, members and switches · stacks when narrow

Workspace

General

How your workspace appears to clients.

Members

3 of 5 seats used.

Leah Morenoleah@loft.example
Owner
Jun Satojun@loft.example
Admin
Rita Banksrita@loft.example
Staff

Billing

Team plan, billed monthly.

Team · 5 seatsRenews on 1 May
Visa ending 4242Expires 08/28

Notifications

What we email you about.

New bookingsAn email for every booking.
CancellationsWhen a client cancels or moves.
Weekly summaryEvery Monday morning.

Security

Keep the workspace locked down.

Two-step sign-inRequired for admins.
Active sessions2 devices signed in.
"use client";
import { useRef, useState } from "react";
import { Bell, CreditCard, Settings, Shield, Users, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";

type Section = { id: string; label: string; icon: LucideIcon; content: React.ReactNode };

const sections: Section[] = [
  { id: "general", label: "General", icon: Settings, content: <p>How your workspace appears to clients.</p> },
  { id: "members", label: "Members", icon: Users, content: <p>3 of 5 seats used.</p> },
  { id: "billing", label: "Billing", icon: CreditCard, content: <p>Team plan, billed monthly.</p> },
  { id: "notifications", label: "Notifications", icon: Bell, content: <p>What we email you about.</p> },
  { id: "security", label: "Security", icon: Shield, content: <p>Keep the workspace locked down.</p> },
];

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

  // Up/Down on wide screens, Left/Right when the list turns into a row.
  const onKeyDown = (e: React.KeyboardEvent) => {
    const last = sections.length - 1;
    const prev = active === 0 ? last : active - 1;
    const next = active === last ? 0 : active + 1;
    const keys: Record<string, number> = { ArrowDown: next, ArrowRight: next, ArrowUp: prev, ArrowLeft: prev, Home: 0, End: last };
    const to = keys[e.key];
    if (to === undefined) return;
    e.preventDefault();
    setActive(to);
    refs.current[to]?.focus();
  };

  return (
    <div className="@container font-sans">
      <div className="grid overflow-hidden rounded-lg border border-border bg-bg @[460px]:min-h-[300px] @[460px]:grid-cols-[172px_minmax(0,1fr)]">
        <div className="grid content-start gap-1.5 border-b border-border bg-surface p-2 @[460px]:border-b-0 @[460px]:border-r">
          <p className="hidden px-2.5 pt-1.5 text-[11.5px] font-medium uppercase tracking-wider text-fg-subtle @[460px]:block">Workspace</p>
          <div
            role="tablist"
            aria-label="Settings"
            aria-orientation="vertical"
            className="flex gap-0.5 overflow-x-auto [scrollbar-width:none] @[460px]:flex-col @[460px]:overflow-visible"
          >
            {sections.map(({ id, label, icon: Icon }, i) => {
              const selected = active === i;
              return (
                <button
                  key={id}
                  ref={(el) => { refs.current[i] = el; }}
                  id={"settings-tab-" + id}
                  type="button"
                  role="tab"
                  aria-selected={selected}
                  aria-controls={"settings-panel-" + id}
                  tabIndex={selected ? 0 : -1}
                  onClick={() => setActive(i)}
                  onKeyDown={onKeyDown}
                  className={cn(
                    "flex h-[34px] shrink-0 items-center gap-2.5 whitespace-nowrap rounded px-2.5 text-left text-[13.5px] font-medium transition-colors",
                    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                    selected ? "bg-bg text-fg shadow-sm ring-1 ring-border [&_svg]:text-primary" : "text-fg-muted hover:bg-surface-2 hover:text-fg",
                  )}
                >
                  <Icon className="size-4 shrink-0" aria-hidden />
                  {label}
                </button>
              );
            })}
          </div>
        </div>
        {sections.map((s, i) => (
          <div
            key={s.id}
            id={"settings-panel-" + s.id}
            role="tabpanel"
            aria-labelledby={"settings-tab-" + s.id}
            hidden={active !== i}
            tabIndex={0}
            className="p-5 text-sm text-fg-muted @[460px]:px-6 @[460px]:py-[22px]"
          >
            <h2 className="text-[15px] font-semibold text-fg">{s.label}</h2>
            <div className="mt-1">{s.content}</div>
          </div>
        ))}
      </div>
    </div>
  );
}
// Uses the Tailwind container-queries plugin (@tailwindcss/container-queries, built into v4).

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