Web InnoventixFreeCode

With Badge Tab

Original · free

tabs with count badges

byWeb InnoventixReact + Tailwind
tabwithbadgetabs
Open

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.json
tab-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&apos;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">&middot;</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 quote

Similar components

Browse all →