With Badge Tab
Original · freetabs with count badges
byWeb InnoventixReact + Tailwind
tabwithbadgetabs
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/tab-with-badge.jsontab-with-badge.tsx
"use client"
import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
type TabId = "inbox" | "assigned" | "escalations" | "drafts" | "archived"
type Priority = "urgent" | "normal" | "low"
type Ticket = {
id: string
subject: string
requester: string
time: string
priority: Priority
}
type Tone = "indigo" | "rose" | "amber"
type TabDef = {
id: TabId
label: string
tone: Tone
}
const TABS: TabDef[] = [
{ id: "inbox", label: "Inbox", tone: "indigo" },
{ id: "assigned", label: "Assigned", tone: "indigo" },
{ id: "escalations", label: "Escalations", tone: "rose" },
{ id: "drafts", label: "Drafts", tone: "amber" },
{ id: "archived", label: "Archived", tone: "indigo" },
]
const TONES: Record<Tone, { idle: string; active: string }> = {
indigo: {
idle: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
active: "bg-indigo-600 text-white dark:bg-indigo-500 dark:text-white",
},
rose: {
idle: "bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300",
active: "bg-rose-600 text-white dark:bg-rose-500 dark:text-white",
},
amber: {
idle: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
active: "bg-amber-500 text-white dark:bg-amber-500 dark:text-white",
},
}
const PRIORITY_DOT: Record<Priority, string> = {
urgent: "bg-rose-500",
normal: "bg-sky-500",
low: "bg-slate-300 dark:bg-slate-600",
}
const PRIORITY_LABEL: Record<Priority, string> = {
urgent: "Urgent",
normal: "Normal priority",
low: "Low priority",
}
const INITIAL: Record<TabId, Ticket[]> = {
inbox: [
{ id: "in-1", subject: "Password reset emails are landing in spam", requester: "Maya Okafor", time: "4m", priority: "urgent" },
{ id: "in-2", subject: "Invoice #4821 shows USD instead of EUR", requester: "Liam Brenner", time: "22m", priority: "normal" },
{ id: "in-3", subject: "SSO login loops back to the sign-in screen", requester: "Priya Nair", time: "1h", priority: "urgent" },
{ id: "in-4", subject: "Dark mode toggle resets after a refresh", requester: "Tom Whitfield", time: "3h", priority: "low" },
],
assigned: [
{ id: "as-1", subject: "Webhook retries are tripping the rate limit", requester: "Deploybot", time: "12m", priority: "normal" },
{ id: "as-2", subject: "CSV export truncates at 10,000 rows", requester: "Hannah Cole", time: "2h", priority: "normal" },
{ id: "as-3", subject: "Docs search returns stale results", requester: "Community forum", time: "5h", priority: "low" },
],
escalations: [
{ id: "es-1", subject: "Nordwind tenant has been down since 09:14 UTC", requester: "Nordwind GmbH", time: "2m", priority: "urgent" },
{ id: "es-2", subject: "Data residency confirmation for the EU workspace", requester: "Legal review", time: "40m", priority: "normal" },
],
drafts: [
{ id: "dr-1", subject: "Re: SLA credit for the March outage", requester: "Draft reply", time: "1d", priority: "low" },
{ id: "dr-2", subject: "Onboarding walkthrough for API v3", requester: "Draft reply", time: "2d", priority: "low" },
],
archived: [],
}
function CheckIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M4.5 10.5l3.2 3.2L15.5 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function ResetIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M15.5 6.5A6 6 0 1 0 16 10" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
<path d="M15.8 3.6v3.1h-3.1" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function EmptyIcon() {
return (
<svg viewBox="0 0 48 48" fill="none" className="h-10 w-10" aria-hidden="true">
<rect x="7" y="12" width="34" height="24" rx="4" stroke="currentColor" strokeWidth="2" />
<path d="M7 26h9l2.5 4h11l2.5-4h9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 19.5l3.4 3.4L29 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export default function TabWithBadge() {
const reduce = useReducedMotion()
const [active, setActive] = useState<TabId>("inbox")
const [data, setData] = useState<Record<TabId, Ticket[]>>(INITIAL)
const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
const totalOpen = Object.values(data).reduce((sum, list) => sum + list.length, 0)
const activeTab = TABS.find((tab) => tab.id === active) ?? TABS[0]
const activeList = data[active]
function resolve(tab: TabId, id: string) {
setData((prev) => ({ ...prev, [tab]: prev[tab].filter((ticket) => ticket.id !== id) }))
}
function reset() {
setData(INITIAL)
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>, index: number) {
const count = TABS.length
let next = index
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % count
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index - 1 + count) % count
else if (event.key === "Home") next = 0
else if (event.key === "End") next = count - 1
else return
event.preventDefault()
setActive(TABS[next].id)
tabRefs.current[next]?.focus()
}
return (
<section className="relative w-full bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:py-24 dark:from-slate-950 dark:to-slate-900">
<style>{`
@keyframes twb-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
@keyframes twb-pop {
0% { transform: scale(0.55); opacity: 0; }
65% { transform: scale(1.14); }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.twb-pulse, .twb-pop { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
{/* Header */}
<div className="flex items-center justify-between gap-4 p-5 sm:p-6">
<div className="min-w-0">
<h2 className="text-base font-semibold text-slate-900 dark:text-white">Support queue</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
<span className="font-medium text-slate-700 tabular-nums dark:text-slate-200">{totalOpen}</span> open across {TABS.length} views
</p>
</div>
<button
type="button"
onClick={reset}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
<ResetIcon />
Reset
</button>
</div>
{/* Tablist */}
<div
role="tablist"
aria-label="Support queue views"
aria-orientation="horizontal"
className="flex gap-1 overflow-x-auto border-b border-slate-200 px-3 dark:border-slate-800 sm:px-4"
>
{TABS.map((tab, index) => {
const count = data[tab.id].length
const isActive = tab.id === active
const tone = TONES[tab.tone]
return (
<button
key={tab.id}
ref={(el) => {
tabRefs.current[index] = el
}}
role="tab"
id={`twb-tab-${tab.id}`}
type="button"
aria-selected={isActive}
aria-controls={`twb-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => setActive(tab.id)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={`relative flex shrink-0 items-center gap-2 whitespace-nowrap rounded-t-lg px-3 py-3.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 ${
isActive
? "text-indigo-600 dark:text-indigo-400"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
<span>{tab.label}</span>
{count > 0 && (
<span
aria-hidden="true"
className={`twb-pop inline-flex min-w-[1.3rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[0.6875rem] font-semibold leading-none tabular-nums ${
isActive ? tone.active : tone.idle
}`}
>
{count}
</span>
)}
<span className="sr-only">{count === 0 ? ", no open items" : `, ${count} open`}</span>
{isActive && (
<motion.span
layoutId="twb-underline"
className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-indigo-500 dark:bg-indigo-400"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 42 }}
/>
)}
</button>
)
})}
</div>
{/* Panels */}
<div className="relative min-h-[19rem] p-3 sm:p-4">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active}
id={`twb-panel-${active}`}
role="tabpanel"
aria-labelledby={`twb-tab-${active}`}
tabIndex={0}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
className="rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
{activeList.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center text-slate-400 dark:text-slate-500">
<EmptyIcon />
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">You're all caught up</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Nothing sitting in {activeTab.label} right now.
</p>
</div>
</div>
) : (
<ul className="flex flex-col gap-1">
{activeList.map((ticket) => (
<li
key={ticket.id}
className="group flex items-start gap-3 rounded-xl border border-transparent px-3 py-3 transition-colors hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-800 dark:hover:bg-slate-800/40"
>
<span className="mt-1.5 flex shrink-0 items-center">
<span
className={`h-2 w-2 rounded-full ${PRIORITY_DOT[ticket.priority]} ${
ticket.priority === "urgent" ? "twb-pulse" : ""
}`}
/>
<span className="sr-only">{PRIORITY_LABEL[ticket.priority]}</span>
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">{ticket.subject}</p>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
{ticket.requester} <span aria-hidden="true">·</span> {ticket.time} ago
</p>
</div>
<button
type="button"
onClick={() => resolve(active, ticket.id)}
aria-label={`Resolve: ${ticket.subject}`}
className="shrink-0 rounded-lg p-1.5 text-slate-400 opacity-100 transition-colors hover:bg-emerald-100 hover:text-emerald-700 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:opacity-0 sm:group-hover:opacity-100 dark:hover:bg-emerald-950 dark:hover:text-emerald-300 dark:focus-visible:ring-offset-slate-900"
>
<CheckIcon />
</button>
</li>
))}
</ul>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Use the arrow keys to move between tabs. Resolve an item to clear it from its badge count.
</p>
</div>
</section>
)
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Underline Tab
Originalunderline tabs with sliding indicator

Pills Tab
Originalpill tabs

Boxed Tab
Originalboxed/segmented tabs

Vertical Tab
Originalvertical tabs
Icons Tab
Originaltabs with icons

Segmented Tab
Originalsegmented control tabs

Scrollable Tab
Originalhorizontally scrollable tabs

Animated Indicator Tab
Originaltabs with a morphing indicator

Cards Tab
Originalcard-style tabs with panels

Line Grow Tab
Originaltabs where the underline grows in

Closable Tab
Originalbrowser-style closable tabs

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

