A card with a title, a row of three underline tabs and a panel area below. Each tab has a small icon and an optional count badge, and each panel holds a different kind of content: stat tiles with a milestone checklist, a people list with hours, and a file list with download links. Clicking a tab swaps the panel, and the left and right arrow keys move between tabs. On phones the three tabs share the width equally and the stat tiles stack into one column.
import { useRef, useState } from "react";
import type { KeyboardEvent } from "react";
const tabs = [
{ id: "overview", label: "Overview", icon: "M4 5h7v6H4zM13 5h7v3h-7zM13 10h7v9h-7zM4 13h7v6H4z" },
{ id: "team", label: "Team", count: 4, icon: "M9 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.5 19a5.5 5.5 0 0 1 11 0M16 11a2.5 2.5 0 1 0 0-5M17 14.5a4.5 4.5 0 0 1 3.5 4.5" },
{ id: "files", label: "Files", count: 5, icon: "M4 7a2 2 0 0 1 2-2h3.5l2 2H18a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7Z" },
];
const stats = [
{ label: "Budget used", value: "$18,400", note: "of $26,000" },
{ label: "Tasks done", value: "37", note: "of 52" },
{ label: "Next review", value: "Thu 14 Mar", note: "with the client" },
];
const milestones = [
{ name: "Brand workshop", date: "12 Feb", done: true },
{ name: "Logo and colour routes", date: "28 Feb", done: true },
{ name: "Packaging mock-ups", date: "21 Mar", done: false },
{ name: "Handover and guidelines", date: "9 Apr", done: false },
];
const team = [
{ initials: "NO", name: "Nadia Okafor", role: "Creative lead", hours: "32 h", tone: "bg-emerald-600" },
{ initials: "TB", name: "Tomas Brandt", role: "Brand designer", hours: "41 h", tone: "bg-sky-600" },
{ initials: "PR", name: "Priya Raman", role: "Copywriter", hours: "18 h", tone: "bg-amber-600" },
{ initials: "JL", name: "Jonah Leclerc", role: "Account manager", hours: "9 h", tone: "bg-rose-600" },
];
const files = [
{ name: "Brand-brief-v3.pdf", size: "2.4 MB", updated: "Updated 2 days ago", kind: "PDF" },
{ name: "Logo-routes-round-2.fig", size: "18.1 MB", updated: "Updated yesterday", kind: "FIG" },
{ name: "Cup-sleeve-mockups.zip", size: "46.7 MB", updated: "Updated 4 hours ago", kind: "ZIP" },
{ name: "Tone-of-voice.docx", size: "310 KB", updated: "Updated 6 Mar", kind: "DOC" },
{ name: "Workshop-notes.md", size: "12 KB", updated: "Updated 13 Feb", kind: "MD" },
];
export default function UnderlineTabsWithIconsAndThreePanels() {
const [active, setActive] = useState("overview");
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const last = tabs.length - 1;
let next = -1;
if (event.key === "ArrowRight") next = index === last ? 0 : index + 1;
if (event.key === "ArrowLeft") next = index === 0 ? last : index - 1;
if (event.key === "Home") next = 0;
if (event.key === "End") next = last;
if (next < 0) return;
event.preventDefault();
setActive(tabs[next].id);
refs.current[next]?.focus();
};
return (
<div className="bg-slate-50 px-4 py-10 text-slate-900 sm:px-8 sm:py-14">
<div className="mx-auto max-w-3xl rounded-2xl border border-slate-200 bg-white">
<div className="px-5 pt-6 sm:px-8 sm:pt-7">
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-700">Client project</p>
<h2 className="mt-1.5 text-xl font-semibold tracking-tight sm:text-2xl">Marlow Coffee rebrand</h2>
</div>
<div role="tablist" aria-label="Project sections" className="mt-5 flex border-b border-slate-200 px-2 sm:px-5">
{tabs.map((tab, index) => {
const selected = tab.id === active;
return (
<button
key={tab.id}
ref={(node) => { refs.current[index] = node; }}
type="button"
role="tab"
id={`tab-${tab.id}`}
aria-selected={selected}
aria-controls={`panel-${tab.id}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(tab.id)}
onKeyDown={(event) => onKeyDown(event, index)}
className={`relative -mb-px flex min-h-[48px] flex-1 items-center justify-center gap-2 rounded-t-lg border-b-2 px-2 text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-600 sm:flex-none sm:px-4 ${selected ? "border-emerald-600 text-emerald-700" : "border-transparent text-slate-600 hover:border-slate-300 hover:text-slate-900"}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-[18px] w-[18px] flex-none">
<path d={tab.icon} />
</svg>
{tab.label}
{tab.count !== undefined && (
<span className={`rounded-full px-1.5 py-0.5 text-xs font-semibold tabular-nums ${selected ? "bg-emerald-100 text-emerald-800" : "bg-slate-100 text-slate-600"}`}>{tab.count}</span>
)}
</button>
);
})}
</div>
<div className="min-h-[392px] px-5 py-6 sm:px-8 sm:py-7">
<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabIndex={0} hidden={active !== "overview"} className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-4">
<dl className="grid gap-px overflow-hidden rounded-xl bg-slate-200 ring-1 ring-slate-200 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="bg-white px-4 py-4">
<dt className="text-xs font-medium text-slate-600">{stat.label}</dt>
<dd className="mt-1 text-lg font-semibold tracking-tight">{stat.value}</dd>
<dd className="text-xs text-slate-600">{stat.note}</dd>
</div>
))}
</dl>
<div className="mt-7 flex items-baseline justify-between">
<h3 className="text-sm font-semibold">Milestones</h3>
<p className="text-xs text-slate-600">2 of 4 complete</p>
</div>
<ul className="mt-3 divide-y divide-slate-100">
{milestones.map((item) => (
<li key={item.name} className="flex items-center gap-3 py-2.5 text-sm">
<span className={`flex h-5 w-5 flex-none items-center justify-center rounded-full ${item.done ? "bg-emerald-600 text-white" : "border-2 border-slate-300"}`}>
{item.done && (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-3 w-3"><path d="m5 10.5 3.2 3.2L15 7" /></svg>
)}
</span>
<span className={item.done ? "text-slate-600 line-through decoration-slate-300" : "font-medium"}>{item.name}</span>
<span className="sr-only">{item.done ? "(done)" : "(to do)"}</span>
<span className="ml-auto flex-none text-xs tabular-nums text-slate-600">{item.date}</span>
</li>
))}
</ul>
</div>
<div role="tabpanel" id="panel-team" aria-labelledby="tab-team" tabIndex={0} hidden={active !== "team"} className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-slate-600">Hours logged this month, across four people.</p>
<button type="button" className="min-h-[40px] flex-none rounded-lg border border-slate-300 px-3.5 text-sm font-semibold text-slate-900 transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2">Add person</button>
</div>
<ul className="mt-4 divide-y divide-slate-100">
{team.map((person) => (
<li key={person.name} className="flex items-center gap-3.5 py-3.5">
<span aria-hidden="true" className={`flex h-10 w-10 flex-none items-center justify-center rounded-full text-xs font-semibold text-white ${person.tone}`}>{person.initials}</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{person.name}</p>
<p className="truncate text-sm text-slate-600">{person.role}</p>
</div>
<p className="ml-auto flex-none text-sm font-medium tabular-nums text-slate-700">{person.hours}</p>
</li>
))}
</ul>
</div>
<div role="tabpanel" id="panel-files" aria-labelledby="tab-files" tabIndex={0} hidden={active !== "files"} className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-4">
<ul className="divide-y divide-slate-100">
{files.map((file) => (
<li key={file.name} className="flex items-center gap-3.5 py-3">
<span aria-hidden="true" className="flex h-10 w-10 flex-none items-center justify-center rounded-lg bg-slate-100 text-[10px] font-bold tracking-wide text-slate-700">{file.kind}</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{file.name}</p>
<p className="truncate text-xs text-slate-600">{file.size} ยท {file.updated}</p>
</div>
<a href="#" aria-label={`Download ${file.name}`} className="ml-auto flex h-10 w-10 flex-none items-center justify-center rounded-lg text-slate-600 transition hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5"><path d="M12 4v11m0 0-4-4m4 4 4-4M5 19h14" /></svg>
</a>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/underline-tabs-with-icons-and-three-panels.json
Keep the id of the active tab in `useState`. Each tab button sets that id on click, and each panel is hidden unless its id matches. Tailwind only handles the look. This component does exactly that with one `active` state value.
Use tabs when there are a few views of similar weight and people will switch between them. Use an accordion when there are many sections, when people may want several open at once, or when the labels are too long to fit in a row.
Three to five short labels fit on a phone. Past that, let the tab row scroll sideways with `overflow-x-auto` or move the extra views into a menu. Avoid wrapping tabs onto a second line, because the underline stops making sense.
Change the starting value in `useState("overview")` to the id of the tab you want. To link to a tab, read the id from the page address when the component loads and pass it as the starting value.
All three panels are in the page markup from the start, so the content can be read by crawlers. Still, put the most important content in the first tab, since that is what every visitor sees.
Related: Pagination with Previous, Next and Page Size Select, Sortable Data Table with Status Badges and Row Actions, Button Set: Primary, Secondary, Ghost, Icon and Loading, Pricing Table with Monthly and Yearly Toggle.
More tabs and navigation or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.